diff --git a/docs/NodeManagers.md b/docs/NodeManagers.md index bc269de477..089461fb31 100644 --- a/docs/NodeManagers.md +++ b/docs/NodeManagers.md @@ -44,6 +44,8 @@ - [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) + - [On-demand virtual node families](#on-demand-virtual-node-families) + - [Monitored-item creation and lifecycle](#monitored-item-creation-and-lifecycle) - [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) @@ -66,6 +68,7 @@ - [Boolean supervision → alarm activation (NAMUR pattern)](#boolean-supervision--alarm-activation-namur-pattern) - [Simulation timers](#simulation-timers) - [Pushing runtime value changes to subscribers](#pushing-runtime-value-changes-to-subscribers) + - [Subscription-gated sources](#subscription-gated-sources) - [Multi-model composition](#multi-model-composition) - [Mixing ModelDesign and NodeSet2 in one project](#mixing-modeldesign-and-nodeset2-in-one-project) - [NodeSet2 access-level bitmasks](#nodeset2-access-level-bitmasks) @@ -858,8 +861,11 @@ declare them on the attribute: ```csharp [NodeManager( - NamespaceUri = Namespaces.Boiler, - AdditionalNamespaceUris = new[] { Namespaces.Boiler + "Instance" })] + NamespaceUri = "http://opcfoundation.org/UA/Boiler/", + AdditionalNamespaceUris = new[] + { + "http://opcfoundation.org/UA/Boiler/Instance" + })] ``` The generated constructor passes them to the base manager together with @@ -870,6 +876,13 @@ namespaces to this manager from the moment it is built. Calling namespace routing from what the manager reported when it was constructed. +The URI expressions must be available to Roslyn before this generator +runs: use string literals, `const` values declared in ordinary source, +or constants from a referenced assembly. A constant emitted by another +generator in the **same compilation** is not available. Such an +expression reports `MODELGEN035` at the offending argument instead of +silently dropping the namespace from the generated manager and factory. + #### Project-wide opt-in via MSBuild property (legacy) If you prefer a generator-derived class identity (`{Prefix}NodeManager` / @@ -904,6 +917,12 @@ namespace MyModel; public partial class MyModelNodeManager { + // The source-generated constructor retains the exact startup + // configuration on FluentNodeManagerBase. Use the protected property + // from any user-authored partial; no custom factory is required. + private MyModelConfiguration? Settings => + Configuration?.ParseExtension(); + partial void Configure(INodeManagerBuilder builder) { builder @@ -961,17 +980,133 @@ The builder exposes: | `OnWrite` / `OnWriteAsync` | `BaseVariableState.OnWriteValue` | | `OnCall` / `OnCallAsync` | `MethodState.OnCallMethod*` | | `OnNodeAdded` / `OnNodeRemoved` | Lifecycle dispatch from `NotifyNodeAdded` | -| `OnEvent`, `OnConditionRefresh`, `OnHistoryRead`, `OnHistoryUpdate`, `OnMonitoredItemCreated` | Manager-level dispatch keyed by `NodeId` | +| `OnEvent`, `OnConditionRefresh`, `OnHistoryRead`, `OnHistoryUpdate` | Node or manager-level dispatch keyed by `NodeId` | +| `OnCreateMonitoredItem`, `OnMonitoredItemCreated`, `OnMonitoredItemModified`, `OnMonitoredItemDeleted`, `OnMonitoringModeChanged` | Data-change monitored-item creation and lifecycle | `INodeManagerBuilder.NodeManager` is typed as `IAsyncNodeManager`. Use `builder.NodeManager.SyncNodeManager` to obtain the synchronous `INodeManager` facade for legacy interop, or cast it to your concrete manager type if you need direct access. -All resolution happens **once** during `CreateAddressSpaceAsync`, -against the in-memory predefined-node tree. There is no reflection, no -`Activator.CreateInstance`, no `Expression.Compile` — the whole pipeline -is NativeAOT-safe. +Ordinary `Node(...)` / `Variable(...)` resolution happens **once** +during `CreateAddressSpaceAsync`, against the in-memory predefined-node +tree. Virtual node families are registered during the same phase but +materialize individual nodes per service operation as described below. +There is no reflection, no `Activator.CreateInstance`, no +`Expression.Compile` — the whole pipeline is NativeAOT-safe. + +#### On-demand virtual node families + +Use `ResolveNodes` when the manager owns a potentially large or external +address space that must not be copied into `PredefinedNodes`. The first +delegate is a cheap ownership test and must not perform I/O. The second +delegate materializes the requested `NodeState` asynchronously: + +```csharp +partial void Configure(INodeManagerBuilder builder) +{ + builder.ResolveNodes( + nodeId => TryParseRegisterId(nodeId, out _), + async (context, nodeId, ct) => + { + RegisterAddress address = ParseRegisterId(nodeId); + RegisterMetadata? metadata = + await m_device.DescribeAsync(address, ct); + if (metadata is null) + { + return null; + } + + return new BaseDataVariableState(parent: null) + { + NodeId = nodeId, + BrowseName = new QualifiedName(metadata.Name, nodeId.NamespaceIndex), + DisplayName = metadata.Name, + DataType = metadata.DataType, + ValueRank = ValueRanks.Scalar + }; + }) + .OnRead(ReadRegister) + .OnWrite(WriteRegister) + .OnCreateBrowser(CreateRegisterBrowser) + .OnMonitoredItemCreated(StartPushSource); +} +``` + +Predefined nodes always win. On a predefined-node miss, +`FluentNodeManagerBase` selects exactly one matching virtual family, +creates an unvalidated `NodeHandle`, and invokes the resolver during +normal node validation. Overlapping predicates fail with +`BadConfigurationError` rather than depending on registration order. + +The resolver may return `null` for a syntactically valid id whose backing +object does not exist. A returned node with `NodeId.Null` receives the +requested id; a conflicting non-null id is rejected. The stack caches the +result only in its existing per-operation and monitored-component caches: +virtual nodes are never inserted into `PredefinedNodes`. + +The returned `IVirtualNodeBuilder` applies one callback template to every +materialized member of the family. It supports read/write/call, +condition/event, history, browser, monitored-item creation, and +monitored-item lifecycle hooks. `OnCreateBrowser` uses the ordinary +`NodeState.CreateBrowser` contract, so custom browsers still participate +in browse filtering, continuation points, and translate-path handling. + +#### Monitored-item creation and lifecycle + +`OnCreateMonitoredItem` runs before the default sampled item is +allocated. It can keep the default path, reject the request with an exact +status, or supply a factory for a custom +`ISampledDataChangeMonitoredItem`: + +```csharp +builder.Node("Buffers/UInt32") + .OnCreateMonitoredItem((request, ct) => + { + if (!request.Request.RequestedParameters.Filter.IsNull) + { + return new ValueTask( + MonitoredItemCreateDecision.Refuse( + StatusCodes.BadFilterNotAllowed)); + } + + if (!request.Request.ItemToMonitor.ParsedIndexRange.IsNull) + { + return new ValueTask( + MonitoredItemCreateDecision.Refuse( + StatusCodes.BadIndexRangeInvalid)); + } + + return new ValueTask( + MonitoredItemCreateDecision.Use( + factory => new BufferMonitoredItem(factory))); + }) + .OnMonitoredItemCreated(OnCreated) + .OnMonitoredItemModified(OnModifiedAsync) + .OnMonitoringModeChanged(OnModeChangedAsync) + .OnMonitoredItemDeleted(OnDeletedAsync); +``` + +The stack allocates the id and owns registration for a custom item. It +supplies the validated filter/range, revised sampling interval and queue +size, manager handle, subscription information, and durability setting +through `MonitoredItemFactoryContext`. The returned item must preserve +that identity and ownership. Both built-in monitored-item managers then +handle modify, monitoring-mode, delete, and manager-lifecycle operations +normally. `Use(factory, queueInitialValue: true)` additionally performs +the standard initial attribute read; push-style items omit it by default. + +Manager-level asynchronous batch hooks receive only successful items and +run after the monitored-item manager has applied its changes: + +```csharp +builder + .OnMonitoredItemsCreated(SubscribeRegisterSlicesAsync) + .OnMonitoredItemsDeleted(UnsubscribeRegisterSlicesAsync); +``` + +The existing synchronous `OnCreateMonitoredItemsComplete` override remains +supported and runs before the new async create-complete hook. #### Creating nodes under other managers' nodes (Objects folder) @@ -1090,7 +1225,10 @@ All emitted types are `internal sealed` because `Configure` is a private partial — the surface never escapes the assembly. Child accessors resolve namespace indices lazily through `ISystemContext.NamespaceUris.GetIndexOrAppend(...)` so the wrappers -work regardless of the namespace-table order at runtime. +work regardless of the namespace-table order at runtime. Object wrappers +use the generated concrete `*State` type when the model declares one, +and manager-level extensions such as `Simulation(...)` work through the +typed proxy just as they do through the untyped builder. #### Methods with arguments — typed `OnCall` overloads @@ -1715,6 +1853,38 @@ infrastructure and therefore **requires** the manager to derive from `FluentNodeManagerBase`; calling it on a plain `CustomNodeManager2` throws `StatusCodes.BadConfigurationError`. +#### Subscription-gated sources + +Use `PollWhileMonitored` when sampling an external source should consume +resources only while a client is interested. A disabled monitored item +does not keep the source active; `Sampling` and `Reporting` items do: + +```csharp +builder.Variable("Dynamic/Temperature") + .OnFirstSubscriber((context, node, ct) => + m_device.StartMonitoringAsync(node.NodeId, ct)) + .OnLastSubscriber((context, node, ct) => + m_device.StopMonitoringAsync(node.NodeId, ct)) + .PollWhileMonitored( + TimeSpan.FromMilliseconds(100), + (context, ct) => m_device.ReadTemperatureAsync(ct)); +``` + +The zero-to-one transition invokes `OnFirstSubscriber`, samples +immediately, and starts the worker. The one-to-zero transition cancels +the worker and invokes `OnLastSubscriber`. While active, the effective +period is the fastest revised sampling interval among active items, +bounded by the minimum period passed to `PollWhileMonitored`. Create, +modify, mode-change, and delete operations reconcile that period without +overlapping samples. The worker uses the server `TimeProvider`, pushes +only changed values through `IValueUpdater`, and is cancelled when +the manager is disposed. + +The same `OnFirstSubscriber`, `OnLastSubscriber`, and +`PollWhileMonitored` extensions are available on an +`IVirtualNodeBuilder`; the current materialized node is retained only for +the monitored-item lifetime. + #### Multi-model composition The only supported mode for combining models is **source-generated diff --git a/src/Opc.Ua.Server/EventIds.cs b/src/Opc.Ua.Server/EventIds.cs index 50c7baaed2..1868a313d1 100644 --- a/src/Opc.Ua.Server/EventIds.cs +++ b/src/Opc.Ua.Server/EventIds.cs @@ -59,6 +59,7 @@ internal static class ServerEventIds public const int JsonUserDatabase = 190; public const int MasterNodeManager = 200; public const int MonitoredItem = 230; + public const int MonitoredSourceRegistry = 240; public const int MonitoredItemQueue = 250; public const int MonitoredNode = 260; public const int NamespaceMetadataPublisher = 270; diff --git a/src/Opc.Ua.Server/Fluent/FluentDelegates.cs b/src/Opc.Ua.Server/Fluent/FluentDelegates.cs index 2a55a96a42..e51ca0047c 100644 --- a/src/Opc.Ua.Server/Fluent/FluentDelegates.cs +++ b/src/Opc.Ua.Server/Fluent/FluentDelegates.cs @@ -28,6 +28,8 @@ * ======================================================================*/ using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Opc.Ua.Server.Fluent { @@ -89,6 +91,59 @@ public delegate void MonitoredItemCreatedHandler( NodeState source, ISampledDataChangeMonitoredItem monitoredItem); + /// + /// Invoked after a monitored item has been modified successfully. + /// + public delegate ValueTask MonitoredItemModifiedHandler( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken); + + /// + /// Invoked after a monitored item has been deleted successfully. + /// + public delegate ValueTask MonitoredItemDeletedHandler( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken); + + /// + /// Invoked after a monitored item's monitoring mode changes. + /// + public delegate ValueTask MonitoringModeChangedHandler( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode previousMode, + MonitoringMode monitoringMode, + CancellationToken cancellationToken); + + /// + /// Invoked before the stack creates a data-change monitored item. + /// + public delegate ValueTask MonitoredItemCreatingHandler( + MonitoredItemCreateContext context, + CancellationToken cancellationToken); + + /// + /// Invoked when a node transitions between zero and one active + /// data-change subscribers. + /// + public delegate ValueTask MonitoredSourceLifecycleHandler( + ISystemContext context, + NodeState source, + CancellationToken cancellationToken); + + /// + /// Invoked after a successful monitored-item create or delete batch. + /// + public delegate ValueTask MonitoredItemsBatchHandler( + ISystemContext context, + ArrayOf monitoredItems, + CancellationToken cancellationToken); + /// /// Invoked when an event is reported by the wired source node. The /// runtime hooks this delegate up via diff --git a/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs b/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs index c674e67e52..3622595536 100644 --- a/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs +++ b/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs @@ -69,8 +69,10 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, namespaceUris) { + Configuration = null; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } /// @@ -82,8 +84,10 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, logger, namespaceUris) { + Configuration = null; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } /// @@ -95,8 +99,10 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, configuration, namespaceUris) { + Configuration = configuration; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } /// @@ -109,8 +115,10 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, configuration, logger, namespaceUris) { + Configuration = configuration; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } /// @@ -123,8 +131,10 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, configuration, useSamplingGroups, namespaceUris) { + Configuration = configuration; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } /// @@ -138,10 +148,24 @@ protected FluentNodeManagerBase( params string[] namespaceUris) : base(server, configuration, useSamplingGroups, logger, namespaceUris) { + Configuration = configuration; EventSources = new EventSourceRegistry(this, m_logger); Simulations = new SimulationRegistry(this, m_logger); + MonitoredSources = new MonitoredSourceRegistry(this, m_logger); } + /// + /// The application configuration supplied when this manager was + /// constructed. Configuration-less legacy constructors expose + /// null. + /// + protected ApplicationConfiguration? Configuration { get; } + + /// + /// The concrete fluent builder attached to this manager. + /// + internal NodeManagerBuilder? AttachedBuilder { get; private set; } + /// /// Registry that the fluent Publish surface stores its /// registered event sources in. Accessed by @@ -159,6 +183,11 @@ protected FluentNodeManagerBase( /// internal SimulationRegistry Simulations { get; } + /// + /// Registry for data sources activated by monitored-item lifecycle. + /// + internal MonitoredSourceRegistry MonitoredSources { get; } + /// /// Constructs a for this /// manager and attaches the fluent event-source / simulation @@ -225,8 +254,82 @@ public void AttachToBuilder(NodeManagerBuilder builder) { throw new System.ArgumentNullException(nameof(builder)); } - builder.AttachEventSources(EventSources); - builder.AttachSimulations(Simulations); + + lock (m_attachedBuildersLock) + { + foreach (NodeManagerBuilder attached in m_attachedBuilders) + { + if (ReferenceEquals(attached, builder)) + { + return; + } + } + + builder.AttachEventSources(EventSources); + builder.AttachSimulations(Simulations); + builder.AttachMonitoredSources(MonitoredSources); + builder.AttachOwner(this); + m_attachedBuilders.Add(builder); + AttachedBuilder = builder; + } + } + + /// + /// Resolves the concrete builder attached to the manager exposed by + /// an arbitrary facade. + /// + internal static NodeManagerBuilder ResolveAttachedBuilder( + INodeManagerBuilder builder, + string feature) + { + if (builder == null) + { + throw new System.ArgumentNullException(nameof(builder)); + } + + if (builder is NodeManagerBuilder concreteBuilder && + concreteBuilder.FluentOwner != null) + { + return concreteBuilder; + } + + if (builder.NodeManager is FluentNodeManagerBase manager && + manager.AttachedBuilder is { } concrete) + { + return concrete; + } + + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "{0} requires the node manager to derive from FluentNodeManagerBase " + + "and attach its builder before Configure runs. Manager type '{1}' does not opt in.", + feature, + builder.NodeManager?.GetType().FullName ?? "(unknown)"); + } + + internal VirtualNodeRegistration? FindVirtualNodeRegistration( + NodeId nodeId) + { + VirtualNodeRegistration? match = null; + foreach (NodeManagerBuilder builder in GetAttachedBuilders()) + { + VirtualNodeRegistration? registration = + builder.FindVirtualNodeRegistration(nodeId); + if (registration == null) + { + continue; + } + if (match != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "NodeId '{0}' matches virtual-node families registered " + + "on more than one fluent builder.", + nodeId); + } + match = registration; + } + return match; } /// @@ -264,6 +367,159 @@ protected ValueTask CompleteConfigureAsync( return AddReverseReferencesAsync(externalReferences, cancellationToken); } + /// + protected override async ValueTask GetManagerHandleAsync( + ServerSystemContext context, + NodeId nodeId, + IDictionary cache, + CancellationToken cancellationToken = default) + { + NodeHandle handle = await base.GetManagerHandleAsync( + context, + nodeId, + cache, + cancellationToken).ConfigureAwait(false); + if (handle != null || !IsNodeIdInNamespace(nodeId)) + { + return handle!; + } + + VirtualNodeRegistration? registration = + FindVirtualNodeRegistration(nodeId); + return registration == null + ? null! + : new NodeHandle + { + NodeId = nodeId, + ParsedNodeId = registration, + Validated = false + }; + } + + /// + protected override async ValueTask ValidateNodeAsync( + ServerSystemContext context, + NodeHandle handle, + IDictionary cache, + CancellationToken cancellationToken = default) + { + NodeState? node = await base.ValidateNodeAsync( + context, + handle, + cache, + cancellationToken).ConfigureAwait(false); + if (node != null || + handle?.ParsedNodeId is not VirtualNodeRegistration registration) + { + return node!; + } + + if (cache != null && cache.TryGetValue(handle.NodeId, out NodeState? cached)) + { + return cached == null + ? null! + : ValidationComplete(context, handle, cached, cache); + } + + NodeState? resolved = await registration.Resolver( + context, + handle.NodeId, + cancellationToken).ConfigureAwait(false); + if (resolved == null) + { + if (cache != null) + { + cache[handle.NodeId] = null!; + } + return null!; + } + + if (resolved.NodeId.IsNull) + { + resolved.NodeId = handle.NodeId; + } + else if (resolved.NodeId != handle.NodeId) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "Virtual-node resolver returned NodeId '{0}' for requested NodeId '{1}'.", + resolved.NodeId, + handle.NodeId); + } + + registration.Apply(resolved); + return ValidationComplete(context, handle, resolved, cache!); + } + + /// + protected override bool TryHandleHistoryRead( + ISystemContext context, + NodeState source, + HistoryReadDetails details, + TimestampsToReturn timestampsToReturn, + bool releaseContinuationPoints, + HistoryReadValueId nodeToRead, + HistoryReadResult result, + out ServiceResult status) + { + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].Dispatcher.TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + releaseContinuationPoints, + nodeToRead, + result, + out status)) + { + return true; + } + } + + return base.TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + releaseContinuationPoints, + nodeToRead, + result, + out status); + } + + /// + protected override bool TryHandleHistoryUpdate( + ISystemContext context, + NodeState source, + HistoryUpdateDetails nodeToUpdate, + HistoryUpdateResult result, + out ServiceResult status) + { + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].Dispatcher.TryHandleHistoryUpdate( + context, + source, + nodeToUpdate, + result, + out status)) + { + return true; + } + } + + return base.TryHandleHistoryUpdate( + context, + source, + nodeToUpdate, + result, + out status); + } + /// /// Signals the registry whenever a notifier's monitored-events /// ref-count flips so the reconcile loop can start or stop the @@ -281,6 +537,332 @@ protected override ValueTask OnSubscribeToEventsAsync( return base.OnSubscribeToEventsAsync(context, monitoredNode, unsubscribe, cancellationToken); } + /// + protected override void OnMonitoredItemCreated( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem) + { + base.OnMonitoredItemCreated(context, handle, monitoredItem); + + if (handle?.Node is not { } node) + { + return; + } + + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].HasMonitoredItemCreatedHandler(node.NodeId)) + { + builders[ii].Dispatcher.NotifyMonitoredItemCreated( + context, + node, + monitoredItem); + break; + } + } + } + + /// + protected override async ValueTask + OnCreatingMonitoredItemAsync( + MonitoredItemCreateContext context, + CancellationToken cancellationToken = default) + { + MonitoredItemCreateDecision decision = + await base.OnCreatingMonitoredItemAsync( + context, + cancellationToken).ConfigureAwait(false); + if (decision.Kind != MonitoredItemCreateDecisionKind.Default) + { + return decision; + } + + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].HasMonitoredItemCreatingHandler( + context.Source.NodeId)) + { + return await builders[ii].Dispatcher + .GetMonitoredItemCreateDecisionAsync( + context, + cancellationToken).ConfigureAwait(false); + } + } + return decision; + } + + /// + protected override async ValueTask OnMonitoredItemModifiedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + await base.OnMonitoredItemModifiedAsync( + context, + handle, + monitoredItem, + cancellationToken).ConfigureAwait(false); + + if (handle?.Node is not { } node) + { + return; + } + + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].HasMonitoredItemModifiedHandler(node.NodeId)) + { + await builders[ii].Dispatcher.NotifyMonitoredItemModifiedAsync( + context, + node, + monitoredItem, + cancellationToken).ConfigureAwait(false); + break; + } + } + + } + + /// + protected override async ValueTask OnMonitoredItemDeletedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + await base.OnMonitoredItemDeletedAsync( + context, + handle, + monitoredItem, + cancellationToken).ConfigureAwait(false); + + if (handle?.Node is not { } node) + { + return; + } + + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].HasMonitoredItemDeletedHandler(node.NodeId)) + { + await builders[ii].Dispatcher.NotifyMonitoredItemDeletedAsync( + context, + node, + monitoredItem, + cancellationToken).ConfigureAwait(false); + break; + } + } + + } + + /// + protected override async ValueTask OnMonitoringModeChangedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode previousMode, + MonitoringMode monitoringMode, + CancellationToken cancellationToken = default) + { + await base.OnMonitoringModeChangedAsync( + context, + handle, + monitoredItem, + previousMode, + monitoringMode, + cancellationToken).ConfigureAwait(false); + + if (handle?.Node is not { } node) + { + return; + } + + NodeManagerBuilder[] builders = GetAttachedBuilders(); + for (int ii = builders.Length - 1; ii >= 0; ii--) + { + if (builders[ii].HasMonitoringModeChangedHandler(node.NodeId)) + { + await builders[ii].Dispatcher.NotifyMonitoringModeChangedAsync( + context, + node, + monitoredItem, + previousMode, + monitoringMode, + cancellationToken).ConfigureAwait(false); + break; + } + } + + } + + /// + protected override async ValueTask OnMonitoredItemAttachedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + await base.OnMonitoredItemAttachedAsync( + context, + handle, + monitoredItem, + cancellationToken).ConfigureAwait(false); + + if (handle?.Node is { } source) + { + await MonitoredSources.OnCreatedAsync( + context, + source, + monitoredItem).ConfigureAwait(false); + } + } + + /// + protected override async ValueTask OnMonitoredItemDetachedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + await base.OnMonitoredItemDetachedAsync( + context, + handle, + monitoredItem, + cancellationToken).ConfigureAwait(false); + + if (handle?.Node is { } source) + { + await MonitoredSources.OnDeletedAsync( + context, + source, + monitoredItem).ConfigureAwait(false); + } + } + + /// + protected override async ValueTask OnCreateMonitoredItemsCompleteAsync( + ServerSystemContext context, + IList monitoredItems, + CancellationToken cancellationToken = default) + { + await base.OnCreateMonitoredItemsCompleteAsync( + context, + monitoredItems, + cancellationToken).ConfigureAwait(false); + + foreach (IMonitoredItem item in monitoredItems) + { + if (item is ISampledDataChangeMonitoredItem sampled && + sampled.ManagerHandle is NodeHandle { Node: { } source }) + { + await MonitoredSources.OnCreatedAsync( + context, + source, + sampled).ConfigureAwait(false); + } + } + + ArrayOf snapshot = + new List(monitoredItems); + foreach (NodeManagerBuilder builder in GetAttachedBuilders()) + { + await builder.Dispatcher.NotifyMonitoredItemsCreatedAsync( + context, + snapshot, + cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override async ValueTask OnDeleteMonitoredItemsCompleteAsync( + ServerSystemContext context, + IList monitoredItems, + CancellationToken cancellationToken = default) + { + await base.OnDeleteMonitoredItemsCompleteAsync( + context, + monitoredItems, + cancellationToken).ConfigureAwait(false); + + foreach (IMonitoredItem item in monitoredItems) + { + if (item is ISampledDataChangeMonitoredItem sampled && + sampled.ManagerHandle is NodeHandle { Node: { } source }) + { + await MonitoredSources.OnDeletedAsync( + context, + source, + sampled).ConfigureAwait(false); + } + } + + ArrayOf snapshot = + new List(monitoredItems); + foreach (NodeManagerBuilder builder in GetAttachedBuilders()) + { + await builder.Dispatcher.NotifyMonitoredItemsDeletedAsync( + context, + snapshot, + cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override async ValueTask OnModifyMonitoredItemsCompleteAsync( + ServerSystemContext context, + IList monitoredItems, + CancellationToken cancellationToken = default) + { + await base.OnModifyMonitoredItemsCompleteAsync( + context, + monitoredItems, + cancellationToken).ConfigureAwait(false); + + foreach (IMonitoredItem item in monitoredItems) + { + if (item is ISampledDataChangeMonitoredItem sampled && + sampled.ManagerHandle is NodeHandle { Node: { } source }) + { + await MonitoredSources.OnModifiedAsync( + context, + source, + sampled).ConfigureAwait(false); + } + } + } + + /// + protected override async ValueTask OnSetMonitoringModeCompleteAsync( + ServerSystemContext context, + IList monitoredItems, + CancellationToken cancellationToken = default) + { + await base.OnSetMonitoringModeCompleteAsync( + context, + monitoredItems, + cancellationToken).ConfigureAwait(false); + + foreach (IMonitoredItem item in monitoredItems) + { + if (item is ISampledDataChangeMonitoredItem sampled && + sampled.ManagerHandle is NodeHandle { Node: { } source }) + { + await MonitoredSources.OnMonitoringModeChangedAsync( + context, + source, + sampled, + sampled.MonitoringMode).ConfigureAwait(false); + } + } + } + /// /// Cancels every running iterator and waits (bounded by each /// source's @@ -292,6 +874,7 @@ protected override void Dispose(bool disposing) { if (disposing) { + MonitoredSources.Dispose(); Simulations.Dispose(); EventSources.Dispose(); } @@ -312,5 +895,16 @@ internal Task AddRootNotifierFromFluentAsync( { return AddRootNotifierAsync(notifier, cancellationToken).AsTask(); } + + private NodeManagerBuilder[] GetAttachedBuilders() + { + lock (m_attachedBuildersLock) + { + return [.. m_attachedBuilders]; + } + } + + private readonly Lock m_attachedBuildersLock = new(); + private readonly List m_attachedBuilders = []; } } diff --git a/src/Opc.Ua.Server/Fluent/IFluentDispatcher.cs b/src/Opc.Ua.Server/Fluent/IFluentDispatcher.cs index 89a70f1b12..948e571997 100644 --- a/src/Opc.Ua.Server/Fluent/IFluentDispatcher.cs +++ b/src/Opc.Ua.Server/Fluent/IFluentDispatcher.cs @@ -27,6 +27,9 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System.Threading; +using System.Threading.Tasks; + namespace Opc.Ua.Server.Fluent { /// @@ -100,6 +103,58 @@ void NotifyMonitoredItemCreated( NodeState source, ISampledDataChangeMonitoredItem monitoredItem); + /// + /// Gets the pre-creation decision registered for a source node. + /// + ValueTask GetMonitoredItemCreateDecisionAsync( + MonitoredItemCreateContext context, + CancellationToken cancellationToken); + + /// + /// Notifies a registered monitored-item modified handler. + /// + ValueTask NotifyMonitoredItemModifiedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken); + + /// + /// Notifies a registered monitored-item deleted handler. + /// + ValueTask NotifyMonitoredItemDeletedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken); + + /// + /// Notifies a registered monitoring-mode changed handler. + /// + ValueTask NotifyMonitoringModeChangedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode previousMode, + MonitoringMode monitoringMode, + CancellationToken cancellationToken); + + /// + /// Notifies the manager-level successful-create batch handler. + /// + ValueTask NotifyMonitoredItemsCreatedAsync( + ISystemContext context, + ArrayOf monitoredItems, + CancellationToken cancellationToken); + + /// + /// Notifies the manager-level successful-delete batch handler. + /// + ValueTask NotifyMonitoredItemsDeletedAsync( + ISystemContext context, + ArrayOf monitoredItems, + CancellationToken cancellationToken); + /// /// Notifies registered OnNodeAdded handlers for the /// supplied node. The generated NodeManagerBase replays diff --git a/src/Opc.Ua.Server/Fluent/INodeBuilder.cs b/src/Opc.Ua.Server/Fluent/INodeBuilder.cs index 3fe620a36a..0e6aa300e7 100644 --- a/src/Opc.Ua.Server/Fluent/INodeBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/INodeBuilder.cs @@ -169,6 +169,27 @@ public interface INodeBuilder : INodeStateBuilder /// INodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler); + /// + /// Participates in monitored-item creation before the default sampled + /// item is allocated. + /// + INodeBuilder OnCreateMonitoredItem(MonitoredItemCreatingHandler handler); + + /// + /// Invoked after a monitored item has been modified successfully. + /// + INodeBuilder OnMonitoredItemModified(MonitoredItemModifiedHandler handler); + + /// + /// Invoked after a monitored item has been deleted successfully. + /// + INodeBuilder OnMonitoredItemDeleted(MonitoredItemDeletedHandler handler); + + /// + /// Invoked after a monitored item's monitoring mode changes. + /// + INodeBuilder OnMonitoringModeChanged(MonitoringModeChangedHandler handler); + /// /// Wires the resolved node's /// hook to forward events to . diff --git a/src/Opc.Ua.Server/Fluent/ISimulationBuilder.cs b/src/Opc.Ua.Server/Fluent/ISimulationBuilder.cs index 8fb2082910..7b1c577df9 100644 --- a/src/Opc.Ua.Server/Fluent/ISimulationBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/ISimulationBuilder.cs @@ -118,17 +118,10 @@ public static ISimulationBuilder Simulation( "Simulation interval must be positive."); } - if (builder is not NodeManagerBuilder concrete || - concrete.Simulations == null) - { - throw ServiceResultException.Create( - StatusCodes.BadConfigurationError, - "Simulation requires the node manager to derive from " + - "FluentNodeManagerBase. Manager type '{0}' does not opt in.", - builder.NodeManager?.GetType().FullName ?? "(unknown)"); - } + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder(builder, "Simulation"); - return concrete.Simulations.NewSimulation(interval); + return concrete.Simulations!.NewSimulation(interval); } } } diff --git a/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs index 177b9caf4f..b01a6f3623 100644 --- a/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs @@ -475,6 +475,46 @@ public INodeBuilder OnConditionRefresh(ConditionRefreshHandler handler) public INodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler) { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemCreated(Node, handler); + } + return this; + } + + public INodeBuilder OnCreateMonitoredItem(MonitoredItemCreatingHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemCreating(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoredItemModified(MonitoredItemModifiedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemModified(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoredItemDeleted(MonitoredItemDeletedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemDeleted(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoringModeChanged(MonitoringModeChangedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoringModeChanged(Node, handler); + } return this; } @@ -486,9 +526,9 @@ public INodeBuilder OnEvent(EventNotificationHandler handler) public INodeBuilder AllowMultipleEventConsumers(bool enable = true) { - if (Builder is NodeManagerBuilder nmb) + if (Builder is NodeManagerBuilder nodeManagerBuilder) { - nmb.RegisterMultiConsumerNode(Node, enable); + nodeManagerBuilder.RegisterMultiConsumerNode(Node, enable); } return this; } diff --git a/src/Opc.Ua.Server/Fluent/MonitoredItemBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/MonitoredItemBuilderExtensions.cs new file mode 100644 index 0000000000..de84286471 --- /dev/null +++ b/src/Opc.Ua.Server/Fluent/MonitoredItemBuilderExtensions.cs @@ -0,0 +1,81 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Manager-level monitored-item batch hooks. + /// + public static class MonitoredItemBuilderExtensions + { + /// + /// Registers an asynchronous callback for successfully created + /// monitored-item batches. + /// + public static INodeManagerBuilder OnMonitoredItemsCreated( + this INodeManagerBuilder builder, + MonitoredItemsBatchHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder( + builder, + "OnMonitoredItemsCreated"); + concrete.RegisterMonitoredItemsCreated(handler); + return builder; + } + + /// + /// Registers an asynchronous callback for successfully deleted + /// monitored-item batches. + /// + public static INodeManagerBuilder OnMonitoredItemsDeleted( + this INodeManagerBuilder builder, + MonitoredItemsBatchHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder( + builder, + "OnMonitoredItemsDeleted"); + concrete.RegisterMonitoredItemsDeleted(handler); + return builder; + } + } +} diff --git a/src/Opc.Ua.Server/Fluent/MonitoredSourceBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/MonitoredSourceBuilderExtensions.cs new file mode 100644 index 0000000000..2a736e210f --- /dev/null +++ b/src/Opc.Ua.Server/Fluent/MonitoredSourceBuilderExtensions.cs @@ -0,0 +1,222 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Subscription-gated source and polling extensions. + /// + public static class MonitoredSourceBuilderExtensions + { + /// + /// Invokes when the first active + /// data-change subscriber appears. + /// + public static INodeBuilder OnFirstSubscriber( + this INodeBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetFirstSubscriber(handler); + return builder; + } + + /// + /// Typed-variable overload that preserves fluent chaining. + /// + /// + /// CLR value type carried by the variable. + /// + public static IVariableBuilder OnFirstSubscriber( + this IVariableBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetFirstSubscriber(handler); + return builder; + } + + /// + /// Invokes when the last active + /// data-change subscriber disappears. + /// + public static INodeBuilder OnLastSubscriber( + this INodeBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetLastSubscriber(handler); + return builder; + } + + /// + /// Typed-variable overload that preserves fluent chaining. + /// + /// + /// CLR value type carried by the variable. + /// + public static IVariableBuilder OnLastSubscriber( + this IVariableBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetLastSubscriber(handler); + return builder; + } + + /// + /// Invokes when the first active + /// data-change subscriber appears on a virtual node. + /// + public static IVirtualNodeBuilder OnFirstSubscriber( + this IVirtualNodeBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetFirstSubscriber(handler); + return builder; + } + + /// + /// Invokes when the last active + /// data-change subscriber disappears from a virtual node. + /// + public static IVirtualNodeBuilder OnLastSubscriber( + this IVirtualNodeBuilder builder, + MonitoredSourceLifecycleHandler handler) + { + GetRegistration(builder).SetLastSubscriber(handler); + return builder; + } + + /// + /// Polls a variable only while it has at least one active subscriber. + /// + /// + /// CLR value type carried by the variable. + /// + public static IVariableBuilder PollWhileMonitored( + this IVariableBuilder builder, + TimeSpan minimumPeriod, + Func> sample) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (sample == null) + { + throw new ArgumentNullException(nameof(sample)); + } + + GetRegistration(builder).SetPoller( + new MonitoredValuePoller( + (context, source, cancellationToken) => + sample(context, cancellationToken)), + minimumPeriod); + return builder; + } + + /// + /// Synchronous convenience overload for monitored polling. + /// + /// + /// CLR value type carried by the variable. + /// + public static IVariableBuilder PollWhileMonitored( + this IVariableBuilder builder, + TimeSpan minimumPeriod, + Func sample) + { + if (sample == null) + { + throw new ArgumentNullException(nameof(sample)); + } + return builder.PollWhileMonitored( + minimumPeriod, + (context, cancellationToken) => + new ValueTask(sample(context))); + } + + /// + /// Polls each materialized virtual variable only while it has an + /// active subscriber. + /// + /// + /// CLR value type returned by the source. + /// + public static IVirtualNodeBuilder PollWhileMonitored( + this IVirtualNodeBuilder builder, + TimeSpan minimumPeriod, + Func< + ISystemContext, + NodeState, + CancellationToken, + ValueTask> sample) + { + if (sample == null) + { + throw new ArgumentNullException(nameof(sample)); + } + GetRegistration(builder).SetPoller( + new MonitoredValuePoller(sample), + minimumPeriod); + return builder; + } + + private static MonitoredSourceRegistration GetRegistration( + INodeBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder( + builder.Builder, + "monitored source"); + return concrete.MonitoredSources!.Register(builder.Node); + } + + private static MonitoredSourceRegistration GetRegistration( + IVirtualNodeBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (builder is not VirtualNodeRegistration registration) + { + throw new ArgumentException( + "The virtual node builder was not created by ResolveNodes.", + nameof(builder)); + } + return registration.Owner.MonitoredSources!.Register(registration); + } + } +} diff --git a/src/Opc.Ua.Server/Fluent/MonitoredSourceRegistry.cs b/src/Opc.Ua.Server/Fluent/MonitoredSourceRegistry.cs new file mode 100644 index 0000000000..54531693cc --- /dev/null +++ b/src/Opc.Ua.Server/Fluent/MonitoredSourceRegistry.cs @@ -0,0 +1,998 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Server.Fluent +{ + internal sealed class MonitoredSourceRegistry : IDisposable + { + public MonitoredSourceRegistry( + FluentNodeManagerBase owner, + ILogger logger) + { + m_owner = owner ?? throw new ArgumentNullException(nameof(owner)); + m_logger = logger; + m_timeProvider = + (owner.Server as ITimeProviderProvider)?.TimeProvider ?? + TimeProvider.System; + m_managerCts = new CancellationTokenSource(); + } + + public MonitoredSourceRegistration Register(NodeState node) + { + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + lock (m_registrationLock) + { + ThrowIfDisposed(); + if (!m_exact.TryGetValue( + node.NodeId, + out MonitoredSourceRegistration? registration)) + { + registration = new MonitoredSourceRegistration( + node.NodeId, + m_timeProvider, + m_logger, + m_managerCts.Token); + m_exact.Add(node.NodeId, registration); + } + return registration; + } + } + + public MonitoredSourceRegistration Register( + VirtualNodeRegistration virtualNodes) + { + if (virtualNodes == null) + { + throw new ArgumentNullException(nameof(virtualNodes)); + } + + lock (m_registrationLock) + { + ThrowIfDisposed(); + if (!m_virtualTemplates.TryGetValue( + virtualNodes, + out MonitoredSourceRegistration? registration)) + { + registration = new MonitoredSourceRegistration( + NodeId.Null, + m_timeProvider, + m_logger, + m_managerCts.Token); + m_virtualTemplates.Add(virtualNodes, registration); + } + return registration; + } + } + + public ValueTask OnCreatedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + MonitoredSourceRegistration? registration = Find( + source.NodeId, + out _); + return registration?.OnCreatedAsync(context, source, monitoredItem) ?? default; + } + + public ValueTask OnModifiedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + MonitoredSourceRegistration? registration = Find( + source.NodeId, + out _); + return registration?.OnModifiedAsync(context, source, monitoredItem) ?? default; + } + + public async ValueTask OnDeletedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + MonitoredSourceRegistration? registration = Find( + source.NodeId, + out VirtualNodeRegistration? virtualNodes); + if (registration == null) + { + return; + } + + bool empty = await registration.OnDeletedAsync( + context, + source, + monitoredItem).ConfigureAwait(false); + if (empty && virtualNodes != null) + { + await RemoveVirtualInstanceAsync( + virtualNodes, + source.NodeId, + registration).ConfigureAwait(false); + } + } + + public ValueTask OnMonitoringModeChangedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode monitoringMode) + { + MonitoredSourceRegistration? registration = Find( + source.NodeId, + out _); + return registration?.OnMonitoringModeChangedAsync( + context, + source, + monitoredItem, + monitoringMode) ?? default; + } + + public void Dispose() + { + List registrations; + lock (m_registrationLock) + { + if (m_disposed) + { + return; + } + m_disposed = true; + m_managerCts.Cancel(); + registrations = [.. m_exact.Values, .. m_virtualTemplates.Values]; + foreach (Dictionary instances + in m_virtualInstances.Values) + { + registrations.AddRange(instances.Values); + } + m_exact.Clear(); + m_virtualTemplates.Clear(); + m_virtualInstances.Clear(); + } + + foreach (MonitoredSourceRegistration registration in registrations) + { + registration.Dispose(); + } + m_managerCts.Dispose(); + } + + private MonitoredSourceRegistration? Find( + NodeId nodeId, + out VirtualNodeRegistration? virtualNodes) + { + lock (m_registrationLock) + { + virtualNodes = null; + if (m_disposed) + { + return null; + } + if (m_exact.TryGetValue( + nodeId, + out MonitoredSourceRegistration? exact)) + { + return exact; + } + + virtualNodes = + m_owner.FindVirtualNodeRegistration(nodeId); + if (virtualNodes == null || + !m_virtualTemplates.TryGetValue( + virtualNodes, + out MonitoredSourceRegistration? template)) + { + return null; + } + + if (!m_virtualInstances.TryGetValue( + virtualNodes, + out Dictionary? instances)) + { + instances = []; + m_virtualInstances.Add(virtualNodes, instances); + } + if (!instances.TryGetValue( + nodeId, + out MonitoredSourceRegistration? registration)) + { + registration = template.CreateForNode(nodeId); + instances.Add(nodeId, registration); + } + return registration; + } + } + + private async ValueTask RemoveVirtualInstanceAsync( + VirtualNodeRegistration virtualNodes, + NodeId nodeId, + MonitoredSourceRegistration registration) + { + bool removed = false; + lock (m_registrationLock) + { + if (m_virtualInstances.TryGetValue( + virtualNodes, + out Dictionary? instances) && + instances.TryGetValue( + nodeId, + out MonitoredSourceRegistration? current) && + ReferenceEquals(current, registration)) + { + instances.Remove(nodeId); + if (instances.Count == 0) + { + m_virtualInstances.Remove(virtualNodes); + } + removed = true; + } + } + + if (removed) + { + await registration.DisposeAsync().ConfigureAwait(false); + } + } + + private void ThrowIfDisposed() + { + if (m_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } + + private readonly FluentNodeManagerBase m_owner; + private readonly ILogger m_logger; + private readonly TimeProvider m_timeProvider; + private readonly CancellationTokenSource m_managerCts; + private readonly Lock m_registrationLock = new(); + private readonly Dictionary m_exact = []; + private readonly Dictionary + m_virtualTemplates = []; + private readonly Dictionary< + VirtualNodeRegistration, + Dictionary> m_virtualInstances = []; + private bool m_disposed; + } + + internal sealed class MonitoredSourceRegistration : + IDisposable, + IAsyncDisposable + { + public MonitoredSourceRegistration( + NodeId nodeId, + TimeProvider timeProvider, + ILogger logger, + CancellationToken managerToken) + { + m_nodeId = nodeId; + m_timeProvider = timeProvider; + m_logger = logger; + m_managerToken = managerToken; + m_updateLock = new SemaphoreSlim(1, 1); + } + + public void SetFirstSubscriber(MonitoredSourceLifecycleHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + if (m_firstSubscriber != null) + { + throw CreateDuplicate("OnFirstSubscriber"); + } + m_firstSubscriber = handler; + } + + public void SetLastSubscriber(MonitoredSourceLifecycleHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + if (m_lastSubscriber != null) + { + throw CreateDuplicate("OnLastSubscriber"); + } + m_lastSubscriber = handler; + } + + public void SetPoller(IMonitoredValuePoller poller, TimeSpan minimumPeriod) + { + if (poller == null) + { + throw new ArgumentNullException(nameof(poller)); + } + if (minimumPeriod <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(minimumPeriod), + minimumPeriod, + "The minimum polling period must be positive."); + } + if (m_poller != null) + { + throw CreateDuplicate("PollWhileMonitored"); + } + m_poller = poller; + m_minimumPeriod = minimumPeriod; + } + + public MonitoredSourceRegistration CreateForNode(NodeId nodeId) + { + var registration = new MonitoredSourceRegistration( + nodeId, + m_timeProvider, + m_logger, + m_managerToken) + { + m_firstSubscriber = m_firstSubscriber, + m_lastSubscriber = m_lastSubscriber, + m_minimumPeriod = m_minimumPeriod, + m_poller = m_poller?.Clone() + }; + return registration; + } + + public async ValueTask OnCreatedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + _ = await UpdateAsync( + context, + source, + monitoredItem, + monitoredItem.MonitoringMode, + remove: false).ConfigureAwait(false); + } + + public async ValueTask OnModifiedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + _ = await UpdateAsync( + context, + source, + monitoredItem, + monitoredItem.MonitoringMode, + remove: false).ConfigureAwait(false); + } + + public ValueTask OnDeletedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem) + { + return UpdateAsync( + context, + source, + monitoredItem, + monitoredItem.MonitoringMode, + remove: true); + } + + public async ValueTask OnMonitoringModeChangedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode monitoringMode) + { + _ = await UpdateAsync( + context, + source, + monitoredItem, + monitoringMode, + remove: false).ConfigureAwait(false); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref m_disposeStarted, 1) != 0) + { + return; + } + + CancellationTokenSource? workerCts = m_workerCts; + Task? worker = m_worker; + workerCts?.Cancel(); + if (workerCts == null) + { + return; + } + + if (worker == null || worker.IsCompleted) + { + workerCts.Dispose(); + return; + } + + _ = worker.ContinueWith( + _ => workerCts.Dispose(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref m_disposeStarted, 1) != 0) + { + return; + } + + CancellationTokenSource? workerCts; + Task? worker; + await m_updateLock.WaitAsync().ConfigureAwait(false); + try + { + workerCts = m_workerCts; + worker = m_worker; + m_workerCts = null; + m_worker = null; + m_items.Clear(); + } + finally + { + m_updateLock.Release(); + } + + workerCts?.Cancel(); + try + { + if (worker != null) + { + await worker.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + finally + { + workerCts?.Dispose(); + m_updateLock.Dispose(); + } + } + + private async ValueTask UpdateAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode monitoringMode, + bool remove) + { + ReconcileAction action; + TimeSpan effectivePeriod; + bool isEmpty; + try + { + await m_updateLock.WaitAsync().ConfigureAwait(false); + try + { + if (Volatile.Read(ref m_disposeStarted) != 0) + { + return false; + } + + bool wasActive = HasActiveItems(); + TimeSpan previousPeriod = GetEffectivePeriod(); + if (remove) + { + m_items.Remove(monitoredItem.Id); + } + else + { + m_items[monitoredItem.Id] = new TrackedItem( + monitoringMode, + monitoredItem.SamplingInterval); + } + + bool isActive = HasActiveItems(); + effectivePeriod = GetEffectivePeriod(); + m_desiredSource = source; + if (!wasActive && isActive) + { + action = ReconcileAction.Activate; + } + else if (wasActive && !isActive) + { + action = ReconcileAction.Deactivate; + } + else if (isActive && + m_poller != null && + effectivePeriod != previousPeriod) + { + action = ReconcileAction.Restart; + } + else + { + action = ReconcileAction.None; + } + + isEmpty = m_items.Count == 0; + } + finally + { + m_updateLock.Release(); + } + + switch (action) + { + case ReconcileAction.Activate: + await InvokeLifecycleAsync( + m_firstSubscriber, + context, + source, + "OnFirstSubscriber").ConfigureAwait(false); + await RestartWorkerAsync( + context, + source, + effectivePeriod).ConfigureAwait(false); + break; + case ReconcileAction.Deactivate: + _ = await StopWorkerAsync( + requireInactive: true, + source, + effectivePeriod).ConfigureAwait(false); + await InvokeLifecycleAsync( + m_lastSubscriber, + context, + source, + "OnLastSubscriber").ConfigureAwait(false); + break; + case ReconcileAction.Restart: + await RestartWorkerAsync( + context, + source, + effectivePeriod).ConfigureAwait(false); + break; + } + + return isEmpty; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + m_logger.MonitoredSourceReconcileFailed( + exception, + FormatNodeId(source.NodeId)); + return false; + } + } + + private async ValueTask RestartWorkerAsync( + ISystemContext context, + NodeState source, + TimeSpan period) + { + if (m_poller == null) + { + return; + } + + bool isCurrent = await StopWorkerAsync( + requireInactive: false, + source, + period).ConfigureAwait(false); + if (!isCurrent) + { + return; + } + + await SampleOnceAsync( + context, + source, + m_managerToken).ConfigureAwait(false); + + CancellationTokenSource? workerCts = null; + try + { + workerCts = CancellationTokenSource.CreateLinkedTokenSource( + m_managerToken); + var startSignal = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var workerStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Task worker = RunWorkerAsync( + context, + source, + period, + startSignal.Task, + workerStarted, + workerCts.Token); + + bool startWorker = false; + await m_updateLock.WaitAsync().ConfigureAwait(false); + try + { + if (Volatile.Read(ref m_disposeStarted) == 0 && + HasActiveItems() && + GetEffectivePeriod() == period && + ReferenceEquals(m_desiredSource, source) && + m_workerCts == null) + { + m_workerCts = workerCts; + m_worker = worker; + workerCts = null; // ownership transferred + startWorker = true; + } + } + finally + { + m_updateLock.Release(); + startSignal.TrySetResult(true); + } + + if (startWorker) + { + await workerStarted.Task.ConfigureAwait(false); + return; + } + + workerCts!.Cancel(); + await worker.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + finally + { + workerCts?.Dispose(); + } + } + + private async ValueTask StopWorkerAsync( + bool requireInactive, + NodeState source, + TimeSpan period) + { + CancellationTokenSource? workerCts; + Task? worker; + await m_updateLock.WaitAsync().ConfigureAwait(false); + try + { + bool stateMatches = requireInactive + ? !HasActiveItems() + : HasActiveItems() && + GetEffectivePeriod() == period && + ReferenceEquals(m_desiredSource, source); + if (Volatile.Read(ref m_disposeStarted) != 0 || !stateMatches) + { + return false; + } + + workerCts = m_workerCts; + worker = m_worker; + m_workerCts = null; + m_worker = null; + } + finally + { + m_updateLock.Release(); + } + + workerCts?.Cancel(); + try + { + if (worker != null) + { + await worker.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + finally + { + workerCts?.Dispose(); + } + return true; + } + + private async Task RunWorkerAsync( + ISystemContext context, + NodeState source, + TimeSpan period, + Task startSignal, + TaskCompletionSource workerStarted, + CancellationToken cancellationToken) + { + await startSignal.ConfigureAwait(false); + bool signalStartup = true; + while (true) + { + try + { + Task delay = m_timeProvider.Delay( + period, + cancellationToken); + if (signalStartup) + { + signalStartup = false; + workerStarted.TrySetResult(true); + } + await delay.ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + + await SampleOnceAsync( + context, + source, + cancellationToken).ConfigureAwait(false); + } + } + + private async ValueTask SampleOnceAsync( + ISystemContext context, + NodeState source, + CancellationToken cancellationToken) + { + try + { + await m_poller!.SampleAsync( + context, + source, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + m_logger.MonitoredSourceSampleFailed( + exception, + FormatNodeId(source.NodeId)); + } + } + + private async ValueTask InvokeLifecycleAsync( + MonitoredSourceLifecycleHandler? handler, + ISystemContext context, + NodeState source, + string callback) + { + if (handler == null) + { + return; + } + + try + { + await handler(context, source, m_managerToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (m_managerToken.IsCancellationRequested) + { + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + m_logger.MonitoredSourceLifecycleFailed( + exception, + callback, + FormatNodeId(source.NodeId)); + } + } + + private bool HasActiveItems() + { + foreach (TrackedItem item in m_items.Values) + { + if (item.Mode != MonitoringMode.Disabled) + { + return true; + } + } + return false; + } + + private TimeSpan GetEffectivePeriod() + { + double fastest = double.PositiveInfinity; + foreach (TrackedItem item in m_items.Values) + { + if (item.Mode == MonitoringMode.Disabled) + { + continue; + } + if (!double.IsNaN(item.SamplingInterval) && + !double.IsInfinity(item.SamplingInterval) && + item.SamplingInterval >= 0 && + item.SamplingInterval < fastest) + { + fastest = item.SamplingInterval; + } + } + + if (double.IsPositiveInfinity(fastest)) + { + return m_minimumPeriod; + } + + TimeSpan requested = TimeSpan.FromMilliseconds(fastest); + return requested > m_minimumPeriod ? requested : m_minimumPeriod; + } + + private ServiceResultException CreateDuplicate(string feature) + { + return ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Node '{0}' already has a {1} registration.", + FormatNodeId(m_nodeId), + feature); + } + + private static string FormatNodeId(NodeId nodeId) + { + return nodeId.IsNull ? "(virtual family)" : nodeId.ToString(); + } + + private readonly record struct TrackedItem( + MonitoringMode Mode, + double SamplingInterval); + + private readonly NodeId m_nodeId; + private readonly TimeProvider m_timeProvider; + private readonly ILogger m_logger; + private readonly CancellationToken m_managerToken; + private readonly SemaphoreSlim m_updateLock; + private readonly Dictionary m_items = []; + private MonitoredSourceLifecycleHandler? m_firstSubscriber; + private MonitoredSourceLifecycleHandler? m_lastSubscriber; + private IMonitoredValuePoller? m_poller; + private TimeSpan m_minimumPeriod; + private NodeState? m_desiredSource; + private CancellationTokenSource? m_workerCts; + private Task? m_worker; + private int m_disposeStarted; + + private enum ReconcileAction + { + None, + Activate, + Deactivate, + Restart + } + } + + internal interface IMonitoredValuePoller + { + IMonitoredValuePoller Clone(); + + ValueTask SampleAsync( + ISystemContext context, + NodeState source, + CancellationToken cancellationToken); + } + + internal sealed class MonitoredValuePoller : IMonitoredValuePoller + { + public MonitoredValuePoller( + Func> sample) + { + m_sample = sample ?? throw new ArgumentNullException(nameof(sample)); + } + + public async ValueTask SampleAsync( + ISystemContext context, + NodeState source, + CancellationToken cancellationToken) + { + if (source is not BaseVariableState variable) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "PollWhileMonitored source '{0}' is not a variable.", + source.NodeId); + } + + TValue current = await m_sample( + context, + source, + cancellationToken).ConfigureAwait(false); + if (!m_hasValue || + !EqualityComparer.Default.Equals(current, m_last)) + { + m_hasValue = true; + m_last = current; + if (m_updater == null || + !ReferenceEquals(m_variable, variable) || + !ReferenceEquals(m_context, context)) + { + m_variable = variable; + m_context = context; + m_updater = new ValueUpdater(variable, context); + } + m_updater.SetValue(current); + } + } + + public IMonitoredValuePoller Clone() + { + return new MonitoredValuePoller(m_sample); + } + + private readonly Func< + ISystemContext, + NodeState, + CancellationToken, + ValueTask> m_sample; + private BaseVariableState? m_variable; + private ISystemContext? m_context; + private ValueUpdater? m_updater; + private TValue? m_last; + private bool m_hasValue; + } + + internal static partial class MonitoredSourceRegistryLog + { + [LoggerMessage( + EventId = ServerEventIds.MonitoredSourceRegistry + 0, + Level = LogLevel.Error, + Message = "Monitored source reconciliation failed for node {NodeId}.")] + public static partial void MonitoredSourceReconcileFailed( + this ILogger logger, + Exception exception, + string nodeId); + + [LoggerMessage( + EventId = ServerEventIds.MonitoredSourceRegistry + 1, + Level = LogLevel.Error, + Message = "Monitored source sample failed for node {NodeId}.")] + public static partial void MonitoredSourceSampleFailed( + this ILogger logger, + Exception exception, + string nodeId); + + [LoggerMessage( + EventId = ServerEventIds.MonitoredSourceRegistry + 2, + Level = LogLevel.Error, + Message = "Monitored source callback {Callback} failed for node {NodeId}.")] + public static partial void MonitoredSourceLifecycleFailed( + this ILogger logger, + Exception exception, + string callback, + string nodeId); + } +} diff --git a/src/Opc.Ua.Server/Fluent/NodeBuilder.cs b/src/Opc.Ua.Server/Fluent/NodeBuilder.cs index 83a035fb4b..0af9289677 100644 --- a/src/Opc.Ua.Server/Fluent/NodeBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/NodeBuilder.cs @@ -245,6 +245,50 @@ public INodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler) return this; } + /// + public INodeBuilder OnCreateMonitoredItem(MonitoredItemCreatingHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_parent.RegisterMonitoredItemCreating(Node, handler); + return this; + } + + /// + public INodeBuilder OnMonitoredItemModified(MonitoredItemModifiedHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_parent.RegisterMonitoredItemModified(Node, handler); + return this; + } + + /// + public INodeBuilder OnMonitoredItemDeleted(MonitoredItemDeletedHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_parent.RegisterMonitoredItemDeleted(Node, handler); + return this; + } + + /// + public INodeBuilder OnMonitoringModeChanged(MonitoringModeChangedHandler handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_parent.RegisterMonitoringModeChanged(Node, handler); + return this; + } + /// public INodeBuilder OnEvent(EventNotificationHandler handler) { diff --git a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index 3fc9b850ef..59fb325fdf 100644 --- a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -29,6 +29,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Opc.Ua.Server.Fluent { @@ -327,6 +329,84 @@ internal VariableBuilder ToVariableBuilder(NodeState node, strin return new VariableBuilder(this, variable); } + internal IVirtualNodeBuilder RegisterVirtualNodes( + VirtualNodeIdPredicate predicate, + VirtualNodeResolver resolver) + { + ThrowIfSealed(); + var registration = new VirtualNodeRegistration(this, predicate, resolver); + m_virtualNodes.Add(registration); + return registration; + } + + internal NodeHandle? CreateVirtualNodeHandle(NodeId nodeId) + { + VirtualNodeRegistration? registration = FindVirtualNodeRegistration(nodeId); + if (registration == null) + { + return null; + } + + return new NodeHandle + { + NodeId = nodeId, + ParsedNodeId = registration, + Validated = false + }; + } + + internal VirtualNodeRegistration? FindVirtualNodeRegistration(NodeId nodeId) + { + VirtualNodeRegistration? match = null; + foreach (VirtualNodeRegistration registration in m_virtualNodes) + { + if (!registration.Predicate(nodeId)) + { + continue; + } + + if (match != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "NodeId '{0}' matches more than one virtual-node family.", + nodeId); + } + match = registration; + } + return match; + } + + internal bool HasMonitoredItemCreatingHandler(NodeId nodeId) + { + return m_monitoredItemCreating.ContainsKey(nodeId) || + FindVirtualNodeRegistration(nodeId)?.MonitoredItemCreating != null; + } + + internal bool HasMonitoredItemCreatedHandler(NodeId nodeId) + { + return m_monitoredItemCreated.ContainsKey(nodeId) || + FindVirtualNodeRegistration(nodeId)?.MonitoredItemCreated != null; + } + + internal bool HasMonitoredItemModifiedHandler(NodeId nodeId) + { + return m_monitoredItemModified.ContainsKey(nodeId) || + FindVirtualNodeRegistration(nodeId)?.MonitoredItemModified != null; + } + + internal bool HasMonitoredItemDeletedHandler(NodeId nodeId) + { + return m_monitoredItemDeleted.ContainsKey(nodeId) || + FindVirtualNodeRegistration(nodeId)?.MonitoredItemDeleted != null; + } + + internal bool HasMonitoringModeChangedHandler(NodeId nodeId) + { + return m_monitoringModeChanged.ContainsKey(nodeId) || + FindVirtualNodeRegistration(nodeId)?.MonitoringModeChanged != null; + } + /// /// Event-source registry owned by the /// ; populated via @@ -382,6 +462,10 @@ internal void AttachEventSources(EventSourceRegistry registry) /// internal SimulationRegistry? Simulations { get; private set; } + internal MonitoredSourceRegistry? MonitoredSources { get; private set; } + + internal FluentNodeManagerBase? FluentOwner { get; private set; } + /// /// Wires the supplied registry into this builder so the /// @@ -400,6 +484,28 @@ internal void AttachSimulations(SimulationRegistry registry) Simulations = registry ?? throw new ArgumentNullException(nameof(registry)); } + internal void AttachMonitoredSources(MonitoredSourceRegistry registry) + { + if (MonitoredSources != null) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "A MonitoredSourceRegistry is already attached to this builder."); + } + MonitoredSources = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + internal void AttachOwner(FluentNodeManagerBase owner) + { + if (FluentOwner != null && !ReferenceEquals(FluentOwner, owner)) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "A different fluent node manager already owns this builder."); + } + FluentOwner = owner ?? throw new ArgumentNullException(nameof(owner)); + } + private static string FormatNodeId(NodeId nodeId) { // NodeId is a readonly struct so the caller may pass `default`; @@ -433,6 +539,20 @@ public bool TryHandleHistoryRead( return true; } + if (node != null && + FindVirtualNodeRegistration(node.NodeId)?.HistoryRead is { } virtualHandler) + { + status = virtualHandler( + context, + node, + details, + timestampsToReturn, + releaseContinuationPoints, + nodeToRead, + result); + return true; + } + status = ServiceResult.Good; return false; } @@ -452,6 +572,13 @@ public bool TryHandleHistoryUpdate( return true; } + if (node != null && + FindVirtualNodeRegistration(node.NodeId)?.HistoryUpdate is { } virtualHandler) + { + status = virtualHandler(context, node, nodeToUpdate, result); + return true; + } + status = ServiceResult.Good; return false; } @@ -466,7 +593,158 @@ public void NotifyMonitoredItemCreated( m_monitoredItemCreated.TryGetValue(source.NodeId, out MonitoredItemCreatedHandler? handler)) { handler(context, source, monitoredItem); + return; + } + + if (source != null && + FindVirtualNodeRegistration(source.NodeId)?.MonitoredItemCreated is { } virtualHandler) + { + virtualHandler(context, source, monitoredItem); + } + } + + /// + public ValueTask GetMonitoredItemCreateDecisionAsync( + MonitoredItemCreateContext context, + CancellationToken cancellationToken) + { + NodeState source = context.Source; + if (m_monitoredItemCreating.TryGetValue( + source.NodeId, + out MonitoredItemCreatingHandler? handler)) + { + return handler(context, cancellationToken); + } + + if (FindVirtualNodeRegistration(source.NodeId)?.MonitoredItemCreating is + { } virtualHandler) + { + return virtualHandler(context, cancellationToken); } + + return new ValueTask( + MonitoredItemCreateDecision.UseDefault()); + } + + /// + public ValueTask NotifyMonitoredItemModifiedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken) + { + if (source != null && + m_monitoredItemModified.TryGetValue( + source.NodeId, + out MonitoredItemModifiedHandler? handler)) + { + return handler(context, source, monitoredItem, cancellationToken); + } + + if (source != null && + FindVirtualNodeRegistration(source.NodeId)?.MonitoredItemModified is + { } virtualHandler) + { + return virtualHandler( + context, + source, + monitoredItem, + cancellationToken); + } + + return default; + } + + /// + public ValueTask NotifyMonitoredItemDeletedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken) + { + if (source != null && + m_monitoredItemDeleted.TryGetValue( + source.NodeId, + out MonitoredItemDeletedHandler? handler)) + { + return handler(context, source, monitoredItem, cancellationToken); + } + + if (source != null && + FindVirtualNodeRegistration(source.NodeId)?.MonitoredItemDeleted is + { } virtualHandler) + { + return virtualHandler( + context, + source, + monitoredItem, + cancellationToken); + } + + return default; + } + + /// + public ValueTask NotifyMonitoringModeChangedAsync( + ISystemContext context, + NodeState source, + ISampledDataChangeMonitoredItem monitoredItem, + MonitoringMode previousMode, + MonitoringMode monitoringMode, + CancellationToken cancellationToken) + { + if (source != null && + m_monitoringModeChanged.TryGetValue( + source.NodeId, + out MonitoringModeChangedHandler? handler)) + { + return handler( + context, + source, + monitoredItem, + previousMode, + monitoringMode, + cancellationToken); + } + + if (source != null && + FindVirtualNodeRegistration(source.NodeId)?.MonitoringModeChanged is + { } virtualHandler) + { + return virtualHandler( + context, + source, + monitoredItem, + previousMode, + monitoringMode, + cancellationToken); + } + + return default; + } + + /// + public ValueTask NotifyMonitoredItemsCreatedAsync( + ISystemContext context, + ArrayOf monitoredItems, + CancellationToken cancellationToken) + { + return m_monitoredItemsCreated?.Invoke( + context, + monitoredItems, + cancellationToken) ?? default; + } + + /// + public ValueTask NotifyMonitoredItemsDeletedAsync( + ISystemContext context, + ArrayOf monitoredItems, + CancellationToken cancellationToken) + { + return m_monitoredItemsDeleted?.Invoke( + context, + monitoredItems, + cancellationToken) ?? default; } /// @@ -507,6 +785,74 @@ internal void RegisterMonitoredItemCreated(NodeState node, MonitoredItemCreatedH m_monitoredItemCreated[node.NodeId] = handler; } + internal void RegisterMonitoredItemCreating( + NodeState node, + MonitoredItemCreatingHandler handler) + { + ThrowIfDuplicate( + m_monitoredItemCreating, + node, + "OnCreateMonitoredItem"); + m_monitoredItemCreating[node.NodeId] = handler; + } + + internal void RegisterMonitoredItemModified( + NodeState node, + MonitoredItemModifiedHandler handler) + { + ThrowIfDuplicate( + m_monitoredItemModified, + node, + "OnMonitoredItemModified"); + m_monitoredItemModified[node.NodeId] = handler; + } + + internal void RegisterMonitoredItemDeleted( + NodeState node, + MonitoredItemDeletedHandler handler) + { + ThrowIfDuplicate( + m_monitoredItemDeleted, + node, + "OnMonitoredItemDeleted"); + m_monitoredItemDeleted[node.NodeId] = handler; + } + + internal void RegisterMonitoringModeChanged( + NodeState node, + MonitoringModeChangedHandler handler) + { + ThrowIfDuplicate( + m_monitoringModeChanged, + node, + "OnMonitoringModeChanged"); + m_monitoringModeChanged[node.NodeId] = handler; + } + + internal void RegisterMonitoredItemsCreated(MonitoredItemsBatchHandler handler) + { + ThrowIfSealed(); + if (m_monitoredItemsCreated != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "A monitored-items-created batch handler is already registered."); + } + m_monitoredItemsCreated = handler; + } + + internal void RegisterMonitoredItemsDeleted(MonitoredItemsBatchHandler handler) + { + ThrowIfSealed(); + if (m_monitoredItemsDeleted != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "A monitored-items-deleted batch handler is already registered."); + } + m_monitoredItemsDeleted = handler; + } + internal void RegisterNodeAdded(NodeState node, NodeLifecycleHandler handler) { ThrowIfDuplicate(m_nodeAdded, node, "OnNodeAdded"); @@ -717,8 +1063,15 @@ private static void ThrowIfDuplicate( private bool m_sealed; private readonly Dictionary m_historyRead = []; private readonly Dictionary m_historyUpdate = []; + private readonly Dictionary m_monitoredItemCreating = []; private readonly Dictionary m_monitoredItemCreated = []; + private readonly Dictionary m_monitoredItemModified = []; + private readonly Dictionary m_monitoredItemDeleted = []; + private readonly Dictionary m_monitoringModeChanged = []; private readonly Dictionary m_nodeAdded = []; private readonly Dictionary m_nodeRemoved = []; + private readonly List m_virtualNodes = []; + private MonitoredItemsBatchHandler? m_monitoredItemsCreated; + private MonitoredItemsBatchHandler? m_monitoredItemsDeleted; } } diff --git a/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs index 71e6a6448e..21cb512d01 100644 --- a/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs @@ -389,6 +389,46 @@ public INodeBuilder OnConditionRefresh(ConditionRefreshHandler handler) public INodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler) { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemCreated(Node, handler); + } + return this; + } + + public INodeBuilder OnCreateMonitoredItem(MonitoredItemCreatingHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemCreating(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoredItemModified(MonitoredItemModifiedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemModified(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoredItemDeleted(MonitoredItemDeletedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoredItemDeleted(Node, handler); + } + return this; + } + + public INodeBuilder OnMonitoringModeChanged(MonitoringModeChangedHandler handler) + { + if (Builder is NodeManagerBuilder nodeManagerBuilder) + { + nodeManagerBuilder.RegisterMonitoringModeChanged(Node, handler); + } return this; } @@ -400,9 +440,9 @@ public INodeBuilder OnEvent(EventNotificationHandler handler) public INodeBuilder AllowMultipleEventConsumers(bool enable = true) { - if (Builder is NodeManagerBuilder nmb) + if (Builder is NodeManagerBuilder nodeManagerBuilder) { - nmb.RegisterMultiConsumerNode(Node, enable); + nodeManagerBuilder.RegisterMultiConsumerNode(Node, enable); } return this; } diff --git a/src/Opc.Ua.Server/Fluent/RuntimeValueBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/RuntimeValueBuilderExtensions.cs index 0dca9d4889..b0a2da50d0 100644 --- a/src/Opc.Ua.Server/Fluent/RuntimeValueBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/RuntimeValueBuilderExtensions.cs @@ -189,15 +189,8 @@ public static IVariableBuilder PollEvery( "Sampling interval must be positive."); } - if (builder.Builder is not NodeManagerBuilder concrete || - concrete.Simulations == null) - { - throw ServiceResultException.Create( - StatusCodes.BadConfigurationError, - "PollEvery requires the node manager to derive from " + - "FluentNodeManagerBase. Manager type '{0}' does not opt in.", - builder.Builder.NodeManager?.GetType().FullName ?? "(unknown)"); - } + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder(builder.Builder, "PollEvery"); ISystemContext context = concrete.Context; var updater = new ValueUpdater(builder.Node, context); @@ -208,7 +201,7 @@ public static IVariableBuilder PollEvery( TValue last = sample(context); updater.SetValue(last); - concrete.Simulations + concrete.Simulations! .NewSimulation(interval) .OnTick((ctx, _) => { diff --git a/src/Opc.Ua.Server/Fluent/VirtualNodeBuilder.cs b/src/Opc.Ua.Server/Fluent/VirtualNodeBuilder.cs new file mode 100644 index 0000000000..bbe2271d47 --- /dev/null +++ b/src/Opc.Ua.Server/Fluent/VirtualNodeBuilder.cs @@ -0,0 +1,557 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Determines whether a virtual-node family recognizes a + /// . + /// + public delegate bool VirtualNodeIdPredicate(NodeId nodeId); + + /// + /// Materializes a virtual node for one service operation. + /// + public delegate ValueTask VirtualNodeResolver( + ISystemContext context, + NodeId nodeId, + CancellationToken cancellationToken); + + /// + /// Configures callbacks shared by a family of nodes that are + /// materialized on demand. + /// + public interface IVirtualNodeBuilder + { + /// + /// Wires . + /// + IVirtualNodeBuilder OnRead(NodeValueEventHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnRead(NodeValueSimpleEventHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnRead(NodeValueEventHandlerAsync handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnWrite(NodeValueEventHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnWrite(NodeValueSimpleEventHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnCall(GenericMethodCalledEventHandler2 handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnCall(GenericMethodCalledEventHandler2Async handler); + + /// + /// Routes history reads for the virtual family. + /// + IVirtualNodeBuilder OnHistoryRead(HistoryReadHandler handler); + + /// + /// Routes history updates for the virtual family. + /// + IVirtualNodeBuilder OnHistoryUpdate(HistoryUpdateHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnConditionRefresh(ConditionRefreshHandler handler); + + /// + /// Invoked after a monitored item is created for a virtual node. + /// + IVirtualNodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler); + + /// + /// Participates in monitored-item creation for the virtual family. + /// + IVirtualNodeBuilder OnCreateMonitoredItem(MonitoredItemCreatingHandler handler); + + /// + /// Invoked after a monitored item has been modified successfully. + /// + IVirtualNodeBuilder OnMonitoredItemModified(MonitoredItemModifiedHandler handler); + + /// + /// Invoked after a monitored item has been deleted successfully. + /// + IVirtualNodeBuilder OnMonitoredItemDeleted(MonitoredItemDeletedHandler handler); + + /// + /// Invoked after a monitored item's monitoring mode changes. + /// + IVirtualNodeBuilder OnMonitoringModeChanged(MonitoringModeChangedHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnEvent(EventNotificationHandler handler); + + /// + /// Wires . + /// + IVirtualNodeBuilder OnCreateBrowser(NodeStateCreateBrowserEventHandler handler); + } + + /// + /// Registers virtual-node families on a fluent node manager. + /// + public static class VirtualNodeBuilderExtensions + { + /// + /// Registers a family of nodes recognized cheaply by + /// and materialized asynchronously by + /// . + /// + public static IVirtualNodeBuilder ResolveNodes( + this INodeManagerBuilder builder, + VirtualNodeIdPredicate predicate, + VirtualNodeResolver resolver) + { + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + if (resolver == null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + NodeManagerBuilder concrete = + FluentNodeManagerBase.ResolveAttachedBuilder(builder, "ResolveNodes"); + return concrete.RegisterVirtualNodes(predicate, resolver); + } + } + + internal sealed class VirtualNodeRegistration : IVirtualNodeBuilder + { + public VirtualNodeRegistration( + NodeManagerBuilder owner, + VirtualNodeIdPredicate predicate, + VirtualNodeResolver resolver) + { + Owner = owner; + Predicate = predicate; + Resolver = resolver; + } + + public NodeManagerBuilder Owner { get; } + + public VirtualNodeIdPredicate Predicate { get; } + + public VirtualNodeResolver Resolver { get; } + + public HistoryReadHandler? HistoryRead { get; private set; } + + public HistoryUpdateHandler? HistoryUpdate { get; private set; } + + public MonitoredItemCreatedHandler? MonitoredItemCreated { get; private set; } + + public MonitoredItemCreatingHandler? MonitoredItemCreating { get; private set; } + + public MonitoredItemModifiedHandler? MonitoredItemModified { get; private set; } + + public MonitoredItemDeletedHandler? MonitoredItemDeleted { get; private set; } + + public MonitoringModeChangedHandler? MonitoringModeChanged { get; private set; } + + public IVirtualNodeBuilder OnRead(NodeValueEventHandler handler) + { + m_read = SetOnce(m_read, handler, "OnRead"); + return this; + } + + public IVirtualNodeBuilder OnRead(NodeValueSimpleEventHandler handler) + { + m_simpleRead = SetOnce(m_simpleRead, handler, "OnSimpleRead"); + return this; + } + + public IVirtualNodeBuilder OnRead(NodeValueEventHandlerAsync handler) + { + m_readAsync = SetOnce(m_readAsync, handler, "OnReadAsync"); + return this; + } + + public IVirtualNodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler) + { + m_simpleReadAsync = SetOnce(m_simpleReadAsync, handler, "OnSimpleReadAsync"); + return this; + } + + public IVirtualNodeBuilder OnWrite(NodeValueEventHandler handler) + { + m_write = SetOnce(m_write, handler, "OnWrite"); + return this; + } + + public IVirtualNodeBuilder OnWrite(NodeValueSimpleEventHandler handler) + { + m_simpleWrite = SetOnce(m_simpleWrite, handler, "OnSimpleWrite"); + return this; + } + + public IVirtualNodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler) + { + m_writeAsync = SetOnce(m_writeAsync, handler, "OnWriteAsync"); + return this; + } + + public IVirtualNodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler) + { + m_simpleWriteAsync = SetOnce(m_simpleWriteAsync, handler, "OnSimpleWriteAsync"); + return this; + } + + public IVirtualNodeBuilder OnCall(GenericMethodCalledEventHandler2 handler) + { + m_call = SetOnce(m_call, handler, "OnCall"); + return this; + } + + public IVirtualNodeBuilder OnCall(GenericMethodCalledEventHandler2Async handler) + { + m_callAsync = SetOnce(m_callAsync, handler, "OnCallAsync"); + return this; + } + + public IVirtualNodeBuilder OnHistoryRead(HistoryReadHandler handler) + { + HistoryRead = SetOnce(HistoryRead, handler, "OnHistoryRead"); + return this; + } + + public IVirtualNodeBuilder OnHistoryUpdate(HistoryUpdateHandler handler) + { + HistoryUpdate = SetOnce(HistoryUpdate, handler, "OnHistoryUpdate"); + return this; + } + + public IVirtualNodeBuilder OnConditionRefresh(ConditionRefreshHandler handler) + { + m_conditionRefresh = SetOnce( + m_conditionRefresh, + handler, + "OnConditionRefresh"); + return this; + } + + public IVirtualNodeBuilder OnMonitoredItemCreated( + MonitoredItemCreatedHandler handler) + { + MonitoredItemCreated = SetOnce( + MonitoredItemCreated, + handler, + "OnMonitoredItemCreated"); + return this; + } + + public IVirtualNodeBuilder OnCreateMonitoredItem( + MonitoredItemCreatingHandler handler) + { + MonitoredItemCreating = SetOnce( + MonitoredItemCreating, + handler, + "OnCreateMonitoredItem"); + return this; + } + + public IVirtualNodeBuilder OnMonitoredItemModified( + MonitoredItemModifiedHandler handler) + { + MonitoredItemModified = SetOnce( + MonitoredItemModified, + handler, + "OnMonitoredItemModified"); + return this; + } + + public IVirtualNodeBuilder OnMonitoredItemDeleted( + MonitoredItemDeletedHandler handler) + { + MonitoredItemDeleted = SetOnce( + MonitoredItemDeleted, + handler, + "OnMonitoredItemDeleted"); + return this; + } + + public IVirtualNodeBuilder OnMonitoringModeChanged( + MonitoringModeChangedHandler handler) + { + MonitoringModeChanged = SetOnce( + MonitoringModeChanged, + handler, + "OnMonitoringModeChanged"); + return this; + } + + public IVirtualNodeBuilder OnEvent(EventNotificationHandler handler) + { + m_event = SetOnce(m_event, handler, "OnEvent"); + return this; + } + + public IVirtualNodeBuilder OnCreateBrowser( + NodeStateCreateBrowserEventHandler handler) + { + m_createBrowser = SetOnce( + m_createBrowser, + handler, + "OnCreateBrowser"); + return this; + } + + public void Apply(NodeState node) + { + lock (m_applyLock) + { + if (m_applied.TryGetValue(node, out _)) + { + return; + } + + if (HasVariableHandlers()) + { + if (node is not BaseVariableState variable) + { + throw CreateTypeMismatch(node, "variable"); + } + + SetSlot(ref variable.OnReadValue, m_read, node, "OnRead"); + SetSlot( + ref variable.OnSimpleReadValue, + m_simpleRead, + node, + "OnSimpleRead"); + SetSlot( + ref variable.OnReadValueAsync, + m_readAsync, + node, + "OnReadAsync"); + SetSlot( + ref variable.OnSimpleReadValueAsync, + m_simpleReadAsync, + node, + "OnSimpleReadAsync"); + SetSlot(ref variable.OnWriteValue, m_write, node, "OnWrite"); + SetSlot( + ref variable.OnSimpleWriteValue, + m_simpleWrite, + node, + "OnSimpleWrite"); + SetSlot( + ref variable.OnWriteValueAsync, + m_writeAsync, + node, + "OnWriteAsync"); + SetSlot( + ref variable.OnSimpleWriteValueAsync, + m_simpleWriteAsync, + node, + "OnSimpleWriteAsync"); + } + + if (m_call != null || m_callAsync != null) + { + if (node is not MethodState method) + { + throw CreateTypeMismatch(node, "method"); + } + + SetSlot(ref method.OnCallMethod2, m_call, node, "OnCall"); + SetSlot( + ref method.OnCallMethod2Async, + m_callAsync, + node, + "OnCallAsync"); + } + + if (m_conditionRefresh != null) + { + if (node.OnConditionRefresh != null) + { + throw CreateOccupiedSlot(node, "OnConditionRefresh"); + } + node.OnConditionRefresh = (context, source, events) => + m_conditionRefresh(context, source, events); + } + + if (m_event != null) + { + if (node.OnReportEvent != null) + { + throw CreateOccupiedSlot(node, "OnEvent"); + } + node.OnReportEvent = (context, source, @event) => + m_event(context, source, @event); + } + + if (m_createBrowser != null) + { + if (node.OnCreateBrowser != null) + { + throw CreateOccupiedSlot(node, "OnCreateBrowser"); + } + node.OnCreateBrowser = m_createBrowser; + } + + m_applied.Add(node, AppliedMarker.Instance); + } + } + + private bool HasVariableHandlers() + { + return m_read != null || + m_simpleRead != null || + m_readAsync != null || + m_simpleReadAsync != null || + m_write != null || + m_simpleWrite != null || + m_writeAsync != null || + m_simpleWriteAsync != null; + } + + private static T SetOnce(T? current, T handler, string name) + where T : Delegate + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + if (current != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The virtual-node family already has a {0} handler registered.", + name); + } + return handler; + } + + private static void SetSlot( + ref T? slot, + T? handler, + NodeState node, + string name) + where T : Delegate + { + if (handler == null) + { + return; + } + if (slot != null) + { + throw CreateOccupiedSlot(node, name); + } + slot = handler; + } + + private static ServiceResultException CreateOccupiedSlot( + NodeState node, + string name) + { + return ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Virtual node '{0}' (id '{1}') already has a {2} handler.", + node.BrowseName, + node.NodeId, + name); + } + + private static ServiceResultException CreateTypeMismatch( + NodeState node, + string expected) + { + return ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Virtual node '{0}' (id '{1}') is not a {2} node.", + node.BrowseName, + node.NodeId, + expected); + } + + private sealed class AppliedMarker + { + public static readonly AppliedMarker Instance = new(); + } + + private readonly ConditionalWeakTable m_applied = new(); + private readonly Lock m_applyLock = new(); + private NodeValueEventHandler? m_read; + private NodeValueSimpleEventHandler? m_simpleRead; + private NodeValueEventHandlerAsync? m_readAsync; + private NodeValueSimpleEventHandlerAsync? m_simpleReadAsync; + private NodeValueEventHandler? m_write; + private NodeValueSimpleEventHandler? m_simpleWrite; + private NodeValueWriteEventHandlerAsync? m_writeAsync; + private NodeValueSimpleWriteEventHandlerAsync? m_simpleWriteAsync; + private GenericMethodCalledEventHandler2? m_call; + private GenericMethodCalledEventHandler2Async? m_callAsync; + private ConditionRefreshHandler? m_conditionRefresh; + private EventNotificationHandler? m_event; + private NodeStateCreateBrowserEventHandler? m_createBrowser; + } +} diff --git a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs index 29b8af1613..e2e483f015 100644 --- a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs @@ -376,10 +376,12 @@ async ValueTask INodeManagerMonitoredItemLifecycle.DetachMonitore } ServerSystemContext context = SystemContext.Copy(new OperationContext(monitoredItem)); + NodeHandle? handle = sampledMonitoredItem.ManagerHandle as NodeHandle; + ServiceResult result; await m_monitoredItemSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - return await DetachMonitoredItemForLifecycleLockedAsync( + result = await DetachMonitoredItemForLifecycleLockedAsync( context, sampledMonitoredItem, lifecycle, @@ -389,6 +391,16 @@ async ValueTask INodeManagerMonitoredItemLifecycle.DetachMonitore { m_monitoredItemSemaphore.Release(); } + + if (ServiceResult.IsGood(result) && handle != null) + { + await OnMonitoredItemDetachedAsync( + context, + handle, + sampledMonitoredItem, + cancellationToken).ConfigureAwait(false); + } + return result; } /// @@ -396,7 +408,9 @@ ValueTask INodeManagerMonitoredItemLifecycle.AttachMonitoredItemA IMonitoredItem monitoredItem, CancellationToken cancellationToken) { - return AttachMonitoredItemForLifecycleAsync(monitoredItem, cancellationToken); + return AttachMonitoredItemAndNotifyAsync( + monitoredItem, + cancellationToken); } /// @@ -404,7 +418,55 @@ ValueTask INodeManagerMonitoredItemLifecycle.RecoverMonitoredItem IMonitoredItem monitoredItem, CancellationToken cancellationToken) { - return AttachMonitoredItemForLifecycleAsync(monitoredItem, cancellationToken); + return AttachMonitoredItemAndNotifyAsync( + monitoredItem, + cancellationToken); + } + + private async ValueTask AttachMonitoredItemAndNotifyAsync( + IMonitoredItem monitoredItem, + CancellationToken cancellationToken) + { + ServiceResult result = await AttachMonitoredItemForLifecycleAsync( + monitoredItem, + cancellationToken).ConfigureAwait(false); + if (ServiceResult.IsGood(result) && + monitoredItem is ISampledDataChangeMonitoredItem sampled && + sampled.ManagerHandle is NodeHandle handle) + { + ServerSystemContext context = + SystemContext.Copy(new OperationContext(monitoredItem)); + await OnMonitoredItemAttachedAsync( + context, + handle, + sampled, + cancellationToken).ConfigureAwait(false); + } + return result; + } + + /// + /// Called after an existing monitored item attaches to this manager. + /// + protected virtual ValueTask OnMonitoredItemAttachedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + return default; + } + + /// + /// Called after an existing monitored item detaches from this manager. + /// + protected virtual ValueTask OnMonitoredItemDetachedAsync( + ServerSystemContext context, + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem, + CancellationToken cancellationToken = default) + { + return default; } private async ValueTask ValidateMonitoredItemForLifecycleAsync( @@ -4470,6 +4532,39 @@ await HistoryReadAsync( return null; } + /// + /// Allows a derived manager to handle a history read before the + /// historian provider pipeline is consulted. + /// + protected virtual bool TryHandleHistoryRead( + ISystemContext context, + NodeState source, + HistoryReadDetails details, + TimestampsToReturn timestampsToReturn, + bool releaseContinuationPoints, + HistoryReadValueId nodeToRead, + HistoryReadResult result, + out ServiceResult status) + { + status = ServiceResult.Good; + return false; + } + + /// + /// Allows a derived manager to handle a history update before the + /// historian provider pipeline is consulted. + /// + protected virtual bool TryHandleHistoryUpdate( + ISystemContext context, + NodeState source, + HistoryUpdateDetails nodeToUpdate, + HistoryUpdateResult result, + out ServiceResult status) + { + status = ServiceResult.Good; + return false; + } + private IHistorianProvider? ResolveHistorianProvider(NodeState node) { return HistorianDispatcher.ResolveProvider(Server, node, GetHistorianProvider(node)); @@ -4642,6 +4737,20 @@ protected virtual async ValueTask HistoryReadRawModifiedAsync( continue; } + if (TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + false, + nodesToRead[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + // Route HistoryRead on an Annotations property to the // parent variable's annotation provider (Part 11 §5.2.7). if (HistorianDispatcher.IsAnnotationsProperty(source)) @@ -4721,6 +4830,20 @@ protected virtual async ValueTask HistoryReadProcessedAsync( continue; } + if (TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + false, + nodesToRead[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (source is not BaseVariableState) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -4777,6 +4900,20 @@ protected virtual async ValueTask HistoryReadAtTimeAsync( continue; } + if (TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + false, + nodesToRead[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (source is not BaseVariableState) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -4826,6 +4963,20 @@ protected virtual async ValueTask HistoryReadEventsAsync( continue; } + if (TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + false, + nodesToRead[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + IHistorianProvider? provider = ResolveHistorianProvider(source); if (provider == null) { @@ -4859,13 +5010,47 @@ protected virtual async ValueTask HistoryReadAsync( // check if continuation points are being released. if (releaseContinuationPoints) { - await HistoryReleaseContinuationPointsAsync( - context, - nodesToRead, - errors, - nodesToProcess, - cache, - cancellationToken).ConfigureAwait(false); + var providerNodes = new List(nodesToProcess.Count); + foreach (NodeHandle handle in nodesToProcess) + { + NodeState source = await ValidateNodeAsync( + context, + handle, + cache, + cancellationToken).ConfigureAwait(false); + if (source == null) + { + continue; + } + + if (TryHandleHistoryRead( + context, + source, + details, + timestampsToReturn, + true, + nodesToRead[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + } + else + { + providerNodes.Add(handle); + } + } + + if (providerNodes.Count > 0) + { + await HistoryReleaseContinuationPointsAsync( + context, + nodesToRead, + errors, + providerNodes, + cache, + cancellationToken).ConfigureAwait(false); + } return; } @@ -5296,6 +5481,17 @@ protected virtual async ValueTask HistoryUpdateDataAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (source is not BaseVariableState) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -5342,6 +5538,17 @@ protected virtual async ValueTask HistoryUpdateStructureDataAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (!HistorianDispatcher.IsAnnotationsProperty(source)) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -5394,6 +5601,17 @@ protected virtual async ValueTask HistoryUpdateEventsAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + IHistorianProvider? provider = ResolveHistorianProvider(source); if (provider == null) { @@ -5429,6 +5647,17 @@ protected virtual async ValueTask HistoryDeleteRawModifiedAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (source is not BaseVariableState) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -5474,6 +5703,17 @@ protected virtual async ValueTask HistoryDeleteAtTimeAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + if (source is not BaseVariableState) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; @@ -5519,6 +5759,17 @@ protected virtual async ValueTask HistoryDeleteEventsAsync( continue; } + if (TryHandleHistoryUpdate( + context, + source, + nodesToUpdate[handle.Index], + results[handle.Index], + out ServiceResult status)) + { + errors[handle.Index] = status; + continue; + } + IHistorianProvider? provider = ResolveHistorianProvider(source); if (provider == null) { @@ -6184,6 +6435,7 @@ public virtual async ValueTask RestoreMonitoredItemsAsync( ServerSystemContext systemContext = SystemContext.Copy(); IDictionary operationCache = new NodeIdDictionary(); var nodesToValidate = new List(); + var restoredItems = new List(); for (int ii = 0; ii < itemsToRestore.Count; ii++) { @@ -6257,6 +6509,7 @@ public virtual async ValueTask RestoreMonitoredItemsAsync( // save the monitored item. monitoredItems[handle.Index] = monitoredItem!; + restoredItems.Add(monitoredItem!); monitoredItem = null; // ownership transferred } finally @@ -6274,6 +6527,10 @@ public virtual async ValueTask RestoreMonitoredItemsAsync( // do any post processing. OnCreateMonitoredItemsComplete(systemContext, monitoredItems); + await OnCreateMonitoredItemsCompleteAsync( + systemContext, + restoredItems, + cancellationToken).ConfigureAwait(false); } /// @@ -6440,6 +6697,10 @@ public virtual async ValueTask CreateMonitoredItemsAsync( // do any post processing. OnCreateMonitoredItemsComplete(systemContext, createdItems); + await OnCreateMonitoredItemsCompleteAsync( + systemContext, + createdItems, + cancellationToken).ConfigureAwait(false); } /// @@ -6452,6 +6713,31 @@ protected virtual void OnCreateMonitoredItemsComplete( // defined by the sub-class } + /// + /// Called asynchronously after a batch of monitored items has been + /// created and committed to the monitored-item manager. + /// + protected virtual ValueTask OnCreateMonitoredItemsCompleteAsync( + ServerSystemContext context, + IList monitoredItems, + CancellationToken cancellationToken = default) + { + // defined by the sub-class + return default; + } + + /// + /// Called before the stack creates a data-change monitored item. + /// + protected virtual ValueTask + OnCreatingMonitoredItemAsync( + MonitoredItemCreateContext context, + CancellationToken cancellationToken = default) + { + return new ValueTask( + MonitoredItemCreateDecision.UseDefault()); + } + /// /// Creates a new set of monitored items for a set of variables. /// @@ -6473,6 +6759,24 @@ protected virtual void OnCreateMonitoredItemsComplete( MonitoringFilterResult? filterResult = null; IMonitoredItem? monitoredItem = null; + var createContext = new MonitoredItemCreateContext( + context, + handle, + subscriptionId, + publishingInterval, + diagnosticsMasks, + timestampsToReturn, + itemToCreate, + createDurable); + MonitoredItemCreateDecision decision = + await OnCreatingMonitoredItemAsync( + createContext, + cancellationToken).ConfigureAwait(false); + if (decision.Kind == MonitoredItemCreateDecisionKind.Refuse) + { + return (decision.Error!, filterResult, monitoredItem); + } + // validate parameters. MonitoringParameters parameters = itemToCreate.RequestedParameters; @@ -6512,8 +6816,37 @@ protected virtual void OnCreateMonitoredItemsComplete( return (validateMonitoringFilterResult.StatusCode, filterResult, monitoredItem); } - ISampledDataChangeMonitoredItem dataChangeMonitoredItem = - m_monitoredItemManager.CreateMonitoredItem( + ISampledDataChangeMonitoredItem dataChangeMonitoredItem; + if (decision.Kind == MonitoredItemCreateDecisionKind.Custom) + { + if (m_monitoredItemManager is not ICustomMonitoredItemManager customManager) + { + return (StatusCodes.BadNotSupported, filterResult, monitoredItem); + } + + dataChangeMonitoredItem = customManager.CreateCustomMonitoredItem( + Server, + this, + context, + handle, + subscriptionId, + publishingInterval, + diagnosticsMasks, + timestampsToReturn, + itemToCreate, + validateMonitoringFilterResult.Range, + validateMonitoringFilterResult.FilterToUse, + samplingInterval, + revisedQueueSize, + createDurable, + monitoredItemId, + AddNodeToComponentCache, + RemoveNodeFromComponentCache, + decision.Factory!); + } + else + { + dataChangeMonitoredItem = m_monitoredItemManager.CreateMonitoredItem( Server, this, context, @@ -6530,20 +6863,31 @@ protected virtual void OnCreateMonitoredItemsComplete( createDurable, monitoredItemId, AddNodeToComponentCache); + } monitoredItem = dataChangeMonitoredItem; // report the initial value. - ServiceResult error = ReadInitialValue(context, handle, dataChangeMonitoredItem); - if (ServiceResult.IsBad(error)) + ServiceResult error = ServiceResult.Good; + if (decision.Kind != MonitoredItemCreateDecisionKind.Custom || + decision.QueueInitialValue) { - if (error.StatusCode == StatusCodes.BadAttributeIdInvalid || - error.StatusCode == StatusCodes.BadDataEncodingInvalid || - error.StatusCode == StatusCodes.BadDataEncodingUnsupported) + error = ReadInitialValue(context, handle, dataChangeMonitoredItem); + if (ServiceResult.IsBad(error)) { - return (error, filterResult, monitoredItem); + if (error.StatusCode == StatusCodes.BadAttributeIdInvalid || + error.StatusCode == StatusCodes.BadDataEncodingInvalid || + error.StatusCode == StatusCodes.BadDataEncodingUnsupported) + { + _ = m_monitoredItemManager.DeleteMonitoredItem( + context, + dataChangeMonitoredItem, + handle); + monitoredItem = null; + return (error, filterResult, monitoredItem); + } + error = StatusCodes.Good; } - error = StatusCodes.Good; } // report change. diff --git a/src/Opc.Ua.Server/NodeManager/MonitoredItem/ICustomMonitoredItemManager.cs b/src/Opc.Ua.Server/NodeManager/MonitoredItem/ICustomMonitoredItemManager.cs new file mode 100644 index 0000000000..f81eccfb52 --- /dev/null +++ b/src/Opc.Ua.Server/NodeManager/MonitoredItem/ICustomMonitoredItemManager.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Server +{ + internal interface ICustomMonitoredItemManager + { + ISampledDataChangeMonitoredItem CreateCustomMonitoredItem( + IServerInternal server, + IAsyncNodeManager nodeManager, + ServerSystemContext context, + NodeHandle handle, + uint subscriptionId, + double publishingInterval, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoredItemCreateRequest itemToCreate, + Range euRange, + MonitoringFilter filterToUse, + double samplingInterval, + uint revisedQueueSize, + bool createDurable, + MonitoredItemIdFactory monitoredItemIdFactory, + Func addNodeToComponentCache, + Action removeNodeFromComponentCache, + MonitoredItemFactory factory); + } + + internal static class CustomMonitoredItemValidation + { + public static void Validate( + MonitoredItemFactoryContext context, + ISampledDataChangeMonitoredItem monitoredItem) + { + if (monitoredItem == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The custom monitored-item factory returned null."); + } + if (monitoredItem.Id != context.MonitoredItemId || + monitoredItem.SubscriptionId != context.SubscriptionId || + monitoredItem.NodeId != context.Handle.NodeId || + !ReferenceEquals(monitoredItem.NodeManager, context.NodeManager) || + !ReferenceEquals(monitoredItem.ManagerHandle, context.Handle) || + (monitoredItem.MonitoredItemType & MonitoredItemTypeMask.DataChange) == 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The custom monitored-item factory returned an item whose identity or ownership " + + "does not match the stack-supplied creation context."); + } + } + } +} diff --git a/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredItemCreateDecision.cs b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredItemCreateDecision.cs new file mode 100644 index 0000000000..00449706ad --- /dev/null +++ b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredItemCreateDecision.cs @@ -0,0 +1,304 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Server +{ + /// + /// Context supplied before the stack creates a data-change monitored item. + /// + public sealed class MonitoredItemCreateContext + { + internal MonitoredItemCreateContext( + ServerSystemContext systemContext, + NodeHandle handle, + uint subscriptionId, + double publishingInterval, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoredItemCreateRequest request, + bool createDurable) + { + SystemContext = systemContext; + Handle = handle; + SubscriptionId = subscriptionId; + PublishingInterval = publishingInterval; + DiagnosticsMasks = diagnosticsMasks; + TimestampsToReturn = timestampsToReturn; + Request = request; + CreateDurable = createDurable; + } + + /// + /// The system context for the create request. + /// + public ServerSystemContext SystemContext { get; } + + /// + /// The validated node handle. + /// + public NodeHandle Handle { get; } + + /// + /// The source node. + /// + public NodeState Source => Handle.Node; + + /// + /// The subscription id. + /// + public uint SubscriptionId { get; } + + /// + /// The subscription publishing interval. + /// + public double PublishingInterval { get; } + + /// + /// The diagnostics mask. + /// + public DiagnosticsMasks DiagnosticsMasks { get; } + + /// + /// The requested timestamps. + /// + public TimestampsToReturn TimestampsToReturn { get; } + + /// + /// The original create request. + /// + public MonitoredItemCreateRequest Request { get; } + + /// + /// Whether the item belongs to a durable subscription. + /// + public bool CreateDurable { get; } + } + + /// + /// Context supplied to a custom monitored-item factory after the stack + /// revises and validates the request. + /// + public sealed class MonitoredItemFactoryContext + { + internal MonitoredItemFactoryContext( + IServerInternal server, + IAsyncNodeManager nodeManager, + ServerSystemContext systemContext, + NodeHandle handle, + uint subscriptionId, + uint monitoredItemId, + double publishingInterval, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoredItemCreateRequest request, + Range euRange, + MonitoringFilter filter, + double samplingInterval, + uint queueSize, + bool createDurable) + { + Server = server; + NodeManager = nodeManager; + SystemContext = systemContext; + Handle = handle; + SubscriptionId = subscriptionId; + MonitoredItemId = monitoredItemId; + PublishingInterval = publishingInterval; + DiagnosticsMasks = diagnosticsMasks; + TimestampsToReturn = timestampsToReturn; + Request = request; + EuRange = euRange; + Filter = filter; + SamplingInterval = samplingInterval; + QueueSize = queueSize; + CreateDurable = createDurable; + } + + /// + /// The owning server. + /// + public IServerInternal Server { get; } + + /// + /// The owning node manager. + /// + public IAsyncNodeManager NodeManager { get; } + + /// + /// The system context for the create request. + /// + public ServerSystemContext SystemContext { get; } + + /// + /// The validated node handle. + /// + public NodeHandle Handle { get; } + + /// + /// The source node. + /// + public NodeState Source => Handle.Node; + + /// + /// The subscription id. + /// + public uint SubscriptionId { get; } + + /// + /// The stack-allocated monitored-item id. + /// + public uint MonitoredItemId { get; } + + /// + /// The subscription publishing interval. + /// + public double PublishingInterval { get; } + + /// + /// The diagnostics mask. + /// + public DiagnosticsMasks DiagnosticsMasks { get; } + + /// + /// The requested timestamps. + /// + public TimestampsToReturn TimestampsToReturn { get; } + + /// + /// The original create request. + /// + public MonitoredItemCreateRequest Request { get; } + + /// + /// The validated engineering-unit range. + /// + public Range EuRange { get; } + + /// + /// The validated filter. + /// + public MonitoringFilter Filter { get; } + + /// + /// The revised sampling interval. + /// + public double SamplingInterval { get; } + + /// + /// The revised queue size. + /// + public uint QueueSize { get; } + + /// + /// Whether the item belongs to a durable subscription. + /// + public bool CreateDurable { get; } + } + + /// + /// Creates a custom sampled data-change monitored item. + /// + public delegate ISampledDataChangeMonitoredItem MonitoredItemFactory( + MonitoredItemFactoryContext context); + + /// + /// Describes how the stack should handle a monitored-item create request. + /// + public sealed class MonitoredItemCreateDecision + { + private MonitoredItemCreateDecision( + MonitoredItemCreateDecisionKind kind, + ServiceResult? error, + MonitoredItemFactory? factory, + bool queueInitialValue) + { + Kind = kind; + Error = error; + Factory = factory; + QueueInitialValue = queueInitialValue; + } + + /// + /// Uses the stack's default sampled monitored item. + /// + public static MonitoredItemCreateDecision UseDefault() + { + return s_default; + } + + /// + /// Refuses the request with . + /// + public static MonitoredItemCreateDecision Refuse(ServiceResult error) + { + return new MonitoredItemCreateDecision( + MonitoredItemCreateDecisionKind.Refuse, + error ?? throw new ArgumentNullException(nameof(error)), + null, + false); + } + + /// + /// Uses a custom item created and registered by the stack. + /// + public static MonitoredItemCreateDecision Use( + MonitoredItemFactory factory, + bool queueInitialValue = false) + { + return new MonitoredItemCreateDecision( + MonitoredItemCreateDecisionKind.Custom, + null, + factory ?? throw new ArgumentNullException(nameof(factory)), + queueInitialValue); + } + + internal MonitoredItemCreateDecisionKind Kind { get; } + + internal ServiceResult? Error { get; } + + internal MonitoredItemFactory? Factory { get; } + + internal bool QueueInitialValue { get; } + + private static readonly MonitoredItemCreateDecision s_default = new( + MonitoredItemCreateDecisionKind.Default, + null, + null, + false); + } + + internal enum MonitoredItemCreateDecisionKind + { + Default, + Refuse, + Custom + } +} diff --git a/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNodeMonitoredItemManager.cs b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNodeMonitoredItemManager.cs index c5607480c7..4abf2b34b2 100644 --- a/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNodeMonitoredItemManager.cs +++ b/src/Opc.Ua.Server/NodeManager/MonitoredItem/MonitoredNodeMonitoredItemManager.cs @@ -42,7 +42,8 @@ namespace Opc.Ua.Server /// public class MonitoredNodeMonitoredItemManager : IMonitoredItemManager, - IMonitoredItemManagerLifecycle + IMonitoredItemManagerLifecycle, + ICustomMonitoredItemManager { /// public MonitoredNodeMonitoredItemManager(IAsyncNodeManager nodeManager, IServerInternal server) @@ -127,6 +128,103 @@ public ISampledDataChangeMonitoredItem CreateMonitoredItem( return datachangeItem; } + /// + ISampledDataChangeMonitoredItem ICustomMonitoredItemManager.CreateCustomMonitoredItem( + IServerInternal server, + IAsyncNodeManager nodeManager, + ServerSystemContext context, + NodeHandle handle, + uint subscriptionId, + double publishingInterval, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoredItemCreateRequest itemToCreate, + Range euRange, + MonitoringFilter filterToUse, + double samplingInterval, + uint revisedQueueSize, + bool createDurable, + MonitoredItemIdFactory monitoredItemIdFactory, + Func addNodeToComponentCache, + Action removeNodeFromComponentCache, + MonitoredItemFactory factory) + { + MonitoredNode2? monitoredNode = null; + ISampledDataChangeMonitoredItem? monitoredItem = null; + bool monitoredNodeCreated = false; + bool monitoredNodeLinked = false; + bool componentCacheAdded = false; + + uint monitoredItemId; + do + { + monitoredItemId = monitoredItemIdFactory.GetNextId(); + } while (!MonitoredItems.TryAdd(monitoredItemId, null!)); + + try + { + if (!MonitoredNodes.TryGetValue(handle.Node.NodeId, out monitoredNode)) + { + NodeState cachedNode = + addNodeToComponentCache(context, handle, handle.Node); + componentCacheAdded = true; + MonitoredNodes[handle.Node.NodeId] = monitoredNode = + new MonitoredNode2( + m_nodeManager, + m_server, + cachedNode, + IsMultiConsumerNode(cachedNode.NodeId)); + monitoredNodeCreated = true; + } + + handle.Node = monitoredNode.Node; + handle.MonitoredNode = monitoredNode; + + var factoryContext = new MonitoredItemFactoryContext( + server, + nodeManager, + context, + handle, + subscriptionId, + monitoredItemId, + publishingInterval, + diagnosticsMasks, + timestampsToReturn, + itemToCreate, + euRange, + filterToUse, + samplingInterval, + revisedQueueSize, + createDurable); + monitoredItem = factory(factoryContext); + CustomMonitoredItemValidation.Validate(factoryContext, monitoredItem); + + monitoredNode.Add(monitoredItem); + monitoredNodeLinked = true; + MonitoredItems[monitoredItemId] = monitoredItem; + return monitoredItem; + } + catch + { + if (monitoredNodeLinked) + { + monitoredNode!.Remove(monitoredItem!); + } + if (monitoredNodeCreated) + { + MonitoredNodes.Remove(handle.NodeId); + monitoredNode?.Dispose(); + } + if (componentCacheAdded) + { + removeNodeFromComponentCache(context, handle); + } + MonitoredItems.TryRemove(monitoredItemId, out _); + monitoredItem?.Dispose(); + throw; + } + } + /// public void ApplyChanges() { diff --git a/src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroupMonitoredItemManager.cs b/src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroupMonitoredItemManager.cs index c60277f565..5381d806db 100644 --- a/src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroupMonitoredItemManager.cs +++ b/src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroupMonitoredItemManager.cs @@ -41,7 +41,8 @@ namespace Opc.Ua.Server /// public class SamplingGroupMonitoredItemManager : IMonitoredItemManager, - IMonitoredItemManagerLifecycle + IMonitoredItemManagerLifecycle, + ICustomMonitoredItemManager { /// public SamplingGroupMonitoredItemManager( @@ -133,6 +134,83 @@ public ISampledDataChangeMonitoredItem CreateMonitoredItem( return monitoredItem; } + /// + ISampledDataChangeMonitoredItem ICustomMonitoredItemManager.CreateCustomMonitoredItem( + IServerInternal server, + IAsyncNodeManager nodeManager, + ServerSystemContext context, + NodeHandle handle, + uint subscriptionId, + double publishingInterval, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoredItemCreateRequest itemToCreate, + Range euRange, + MonitoringFilter filterToUse, + double samplingInterval, + uint revisedQueueSize, + bool createDurable, + MonitoredItemIdFactory monitoredItemIdFactory, + Func addNodeToComponentCache, + Action removeNodeFromComponentCache, + MonitoredItemFactory factory) + { + _ = addNodeToComponentCache; + _ = removeNodeFromComponentCache; + + if (samplingInterval.CompareTo(0.0) == 0) + { + samplingInterval = 1; + } + + uint monitoredItemId; + do + { + monitoredItemId = monitoredItemIdFactory.GetNextId(); + } while (!MonitoredItems.TryAdd(monitoredItemId, null!)); + + ISampledDataChangeMonitoredItem? monitoredItem = null; + bool monitoringStarted = false; + try + { + var factoryContext = new MonitoredItemFactoryContext( + server, + nodeManager, + context, + handle, + subscriptionId, + monitoredItemId, + publishingInterval, + diagnosticsMasks, + timestampsToReturn, + itemToCreate, + euRange, + filterToUse, + samplingInterval, + revisedQueueSize, + createDurable); + monitoredItem = factory(factoryContext); + CustomMonitoredItemValidation.Validate(factoryContext, monitoredItem); + + m_samplingGroupManager.StartMonitoring( + context.OperationContext!, + monitoredItem); + monitoringStarted = true; + MonitoredItems[monitoredItemId] = monitoredItem; + return monitoredItem; + } + catch + { + if (monitoringStarted) + { + m_samplingGroupManager.StopMonitoring(monitoredItem!); + } + MonitoredItems.TryRemove(monitoredItemId, out _); + monitoredItem?.Dispose(); + throw; + } + } + /// public void ApplyChanges() { diff --git a/tests/Opc.Ua.Server.Tests/Fluent/MonitoredItemFluentTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/MonitoredItemFluentTests.cs new file mode 100644 index 0000000000..38a24aeb13 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Fluent/MonitoredItemFluentTests.cs @@ -0,0 +1,765 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Fluent; + +#nullable enable + +namespace Opc.Ua.Server.Tests.Fluent +{ + /// + /// Tests fluent monitored-item creation and lifecycle routing. + /// + [TestFixture] + [Category("Fluent")] + public sealed class MonitoredItemFluentTests + { + [Test] + public async Task PreCreationHandlerCanRefuseRequestsAsync() + { + using var harness = await MonitoredItemHarness.CreateAsync(builder => + { + builder.Node("Value") + .OnCreateMonitoredItem((context, cancellationToken) => + { + ReadValueId item = context.Request.ItemToMonitor; + if (!context.Request.RequestedParameters.Filter.IsNull) + { + return new ValueTask( + MonitoredItemCreateDecision.Refuse( + StatusCodes.BadFilterNotAllowed)); + } + if (!item.ParsedIndexRange.IsNull) + { + return new ValueTask( + MonitoredItemCreateDecision.Refuse( + StatusCodes.BadIndexRangeInvalid)); + } + if (!item.DataEncoding.IsNull) + { + return new ValueTask( + MonitoredItemCreateDecision.Refuse( + StatusCodes.BadDataEncodingUnsupported)); + } + return new ValueTask( + MonitoredItemCreateDecision.UseDefault()); + }); + }).ConfigureAwait(false); + + ServiceResult filterResult = await harness.CreateAndGetErrorAsync( + CreateRequest(filter: new ExtensionObject(new DataChangeFilter()))) + .ConfigureAwait(false); + ServiceResult rangeResult = await harness.CreateAndGetErrorAsync( + CreateRequest( + indexRange: "0", + parsedIndexRange: NumericRange.Parse("0"))) + .ConfigureAwait(false); + ServiceResult encodingResult = await harness.CreateAndGetErrorAsync( + CreateRequest(dataEncoding: new QualifiedName("Default Binary"))) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + filterResult.StatusCode, + Is.EqualTo(StatusCodes.BadFilterNotAllowed)); + Assert.That( + rangeResult.StatusCode, + Is.EqualTo(StatusCodes.BadIndexRangeInvalid)); + Assert.That( + encodingResult.StatusCode, + Is.EqualTo(StatusCodes.BadDataEncodingUnsupported)); + Assert.That(harness.OwnedMonitoredItemCount, Is.Zero); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task CustomItemUsesStackLifecycleAndBatchHooksAsync( + bool useSamplingGroups) + { + int created = 0; + int modified = 0; + int deleted = 0; + int modeChanged = 0; + int createBatches = 0; + int deleteBatches = 0; + + using var harness = await MonitoredItemHarness.CreateAsync( + builder => + { + builder.OnMonitoredItemsCreated((context, items, cancellationToken) => + { + createBatches++; + Assert.That(items.Count, Is.EqualTo(1)); + return default; + }); + builder.OnMonitoredItemsDeleted((context, items, cancellationToken) => + { + deleteBatches++; + Assert.That(items.Count, Is.EqualTo(1)); + return default; + }); + builder.Node("Value") + .OnCreateMonitoredItem((context, cancellationToken) => + new ValueTask( + MonitoredItemCreateDecision.Use( + factoryContext => + new PushMonitoredItem(factoryContext)))) + .OnMonitoredItemCreated((context, node, item) => created++) + .OnMonitoredItemModified((context, node, item, cancellationToken) => + { + modified++; + return default; + }) + .OnMonitoredItemDeleted((context, node, item, cancellationToken) => + { + deleted++; + return default; + }) + .OnMonitoringModeChanged(( + context, + node, + item, + previousMode, + monitoringMode, + cancellationToken) => + { + modeChanged++; + Assert.That(previousMode, Is.EqualTo(MonitoringMode.Reporting)); + Assert.That(monitoringMode, Is.EqualTo(MonitoringMode.Sampling)); + return default; + }); + }, + useSamplingGroups).ConfigureAwait(false); + + (ServiceResult createResult, IMonitoredItem? item) = + await harness.CreateAsync(CreateRequest()).ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(createResult), Is.True); + Assert.That(item, Is.TypeOf()); + + var pushed = new DataValue(Variant.From(123), StatusCodes.Good); + ((IDataChangeMonitoredItem2)item!).QueueValue(pushed, ServiceResult.Good, true); + + ServiceResult modifyResult = await harness.ModifyAsync(item) + .ConfigureAwait(false); + ServiceResult modeResult = await harness.SetModeAsync( + item, + MonitoringMode.Sampling).ConfigureAwait(false); + ServiceResult deleteResult = await harness.DeleteAsync(item) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(ServiceResult.IsGood(modifyResult), Is.True); + Assert.That(ServiceResult.IsGood(modeResult), Is.True); + Assert.That(ServiceResult.IsGood(deleteResult), Is.True); + Assert.That(created, Is.EqualTo(1)); + Assert.That(modified, Is.EqualTo(1)); + Assert.That(modeChanged, Is.EqualTo(1)); + Assert.That(deleted, Is.EqualTo(1)); + Assert.That(createBatches, Is.EqualTo(1)); + Assert.That(deleteBatches, Is.EqualTo(1)); + Assert.That(harness.OwnedMonitoredItemCount, Is.Zero); + }); + } + + [Test] + public async Task PollWhileMonitoredTracksActiveItemsAndFastestIntervalAsync() + { + int samples = 0; + int firstSubscribers = 0; + int lastSubscribers = 0; + + using var harness = await MonitoredItemHarness.CreateAsync(builder => + { + IVariableBuilder variable = builder.Variable("Value"); + variable.OnFirstSubscriber((context, node, cancellationToken) => + { + firstSubscribers++; + return default; + }); + variable.OnLastSubscriber((context, node, cancellationToken) => + { + lastSubscribers++; + return default; + }); + variable.PollWhileMonitored( + TimeSpan.FromMilliseconds(50), + context => Interlocked.Increment(ref samples)); + }).ConfigureAwait(false); + + harness.Time.Advance(TimeSpan.FromSeconds(1)); + await DrainAsync().ConfigureAwait(false); + Assert.That(samples, Is.Zero); + + (_, IMonitoredItem? slowItem) = await harness.CreateAsync( + CreateRequest(samplingInterval: 300)).ConfigureAwait(false); + await DrainAsync().ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(samples, Is.EqualTo(1)); + Assert.That(firstSubscribers, Is.EqualTo(1)); + Assert.That(lastSubscribers, Is.Zero); + }); + + harness.Time.Advance(TimeSpan.FromMilliseconds(299)); + await DrainAsync().ConfigureAwait(false); + Assert.That(samples, Is.EqualTo(1)); + harness.Time.Advance(TimeSpan.FromMilliseconds(1)); + await WaitForAsync( + () => Volatile.Read(ref samples) == 2, + "The 300 ms poll must run after fake time reaches its due time.") + .ConfigureAwait(false); + Assert.That(samples, Is.EqualTo(2)); + + (_, IMonitoredItem? fastItem) = await harness.CreateAsync( + CreateRequest(samplingInterval: 100)).ConfigureAwait(false); + await DrainAsync().ConfigureAwait(false); + int afterFastActivation = samples; + harness.Time.Advance(TimeSpan.FromMilliseconds(100)); + await WaitForAsync( + () => Volatile.Read(ref samples) == afterFastActivation + 1, + "The fastest active 100 ms poll must run after its due time.") + .ConfigureAwait(false); + Assert.That(samples, Is.EqualTo(afterFastActivation + 1)); + Assert.That(firstSubscribers, Is.EqualTo(1)); + + await harness.SetModeAsync( + fastItem!, + MonitoringMode.Disabled).ConfigureAwait(false); + await harness.SetModeAsync( + slowItem!, + MonitoringMode.Disabled).ConfigureAwait(false); + await DrainAsync().ConfigureAwait(false); + int stoppedAt = samples; + harness.Time.Advance(TimeSpan.FromSeconds(1)); + await DrainAsync().ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(samples, Is.EqualTo(stoppedAt)); + Assert.That(lastSubscribers, Is.EqualTo(1)); + }); + + await harness.SetModeAsync( + slowItem!, + MonitoringMode.Reporting).ConfigureAwait(false); + await DrainAsync().ConfigureAwait(false); + Assert.That(firstSubscribers, Is.EqualTo(2)); + + await harness.DeleteAsync(fastItem!).ConfigureAwait(false); + await harness.DeleteAsync(slowItem!).ConfigureAwait(false); + await DrainAsync().ConfigureAwait(false); + Assert.That(lastSubscribers, Is.EqualTo(2)); + } + + [Test] + public async Task FirstSubscriberCanReenterMonitoringOperationsAsync() + { + MonitoredItemHarness harness = null!; + IMonitoredItem? capturedItem = null; + int firstSubscribers = 0; + int lastSubscribers = 0; + int samples = 0; + + harness = await MonitoredItemHarness.CreateAsync(builder => + { + IVariableBuilder variable = builder.Variable("Value"); + variable.OnMonitoredItemCreated( + (context, node, item) => capturedItem = item); + variable.OnFirstSubscriber(async (context, node, cancellationToken) => + { + firstSubscribers++; + ServiceResult result = await harness.SetModeAsync( + capturedItem!, + MonitoringMode.Disabled).ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(result), Is.True); + }); + variable.OnLastSubscriber((context, node, cancellationToken) => + { + lastSubscribers++; + return default; + }); + variable.PollWhileMonitored( + TimeSpan.FromMilliseconds(50), + context => Interlocked.Increment(ref samples)); + }).ConfigureAwait(false); + using (harness) + { + Task<(ServiceResult Error, IMonitoredItem? Item)> createTask = + harness.CreateAsync(CreateRequest()).AsTask(); + Task completed = await Task.WhenAny( + createTask, + Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); + + Assert.That( + completed, + Is.SameAs(createTask), + "OnFirstSubscriber re-entry must not deadlock reconciliation."); + (ServiceResult error, IMonitoredItem? item) = + await createTask.ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(ServiceResult.IsGood(error), Is.True); + Assert.That(item, Is.Not.Null); + Assert.That(item!.MonitoringMode, Is.EqualTo(MonitoringMode.Disabled)); + Assert.That(firstSubscribers, Is.EqualTo(1)); + Assert.That(lastSubscribers, Is.EqualTo(1)); + Assert.That(samples, Is.Zero); + }); + + await harness.DeleteAsync(item!).ConfigureAwait(false); + } + } + + [TestCase(false)] + [TestCase(true)] + public async Task CustomFactoryFailureRollsBackRegistrationAsync( + bool useSamplingGroups) + { + using var harness = await MonitoredItemHarness.CreateAsync( + builder => + { + builder.Node("Value") + .OnCreateMonitoredItem((context, cancellationToken) => + new ValueTask( + MonitoredItemCreateDecision.Use( + factoryContext => + throw new InvalidOperationException("factory failed")))); + }, + useSamplingGroups).ConfigureAwait(false); + + Assert.ThrowsAsync( + async () => await harness.CreateAsync(CreateRequest()) + .ConfigureAwait(false)); + Assert.Multiple(() => + { + Assert.That(harness.OwnedMonitoredItemCount, Is.Zero); + Assert.That(harness.OwnedMonitoredNodeCount, Is.Zero); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task CustomItemCanQueueInitialValueAsync( + bool useSamplingGroups) + { + using var harness = await MonitoredItemHarness.CreateAsync( + builder => + { + builder.Node("Value") + .OnCreateMonitoredItem((context, cancellationToken) => + new ValueTask( + MonitoredItemCreateDecision.Use( + factoryContext => + new PushMonitoredItem(factoryContext), + queueInitialValue: true))); + }, + useSamplingGroups).ConfigureAwait(false); + + (ServiceResult error, IMonitoredItem? item) = + await harness.CreateAsync(CreateRequest()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(ServiceResult.IsGood(error), Is.True); + Assert.That(item, Is.Not.Null); + Assert.That(item!.IsReadyToPublish, Is.True); + }); + await harness.DeleteAsync(item!).ConfigureAwait(false); + } + + [TestCase(false)] + [TestCase(true)] + public async Task FatalInitialReadRollsBackCustomItemAsync( + bool useSamplingGroups) + { + using var harness = await MonitoredItemHarness.CreateAsync( + builder => + { + builder.Node("Value") + .OnCreateMonitoredItem((context, cancellationToken) => + new ValueTask( + MonitoredItemCreateDecision.Use( + factoryContext => + new PushMonitoredItem(factoryContext), + queueInitialValue: true))); + }, + useSamplingGroups).ConfigureAwait(false); + + (ServiceResult error, IMonitoredItem? item) = await harness.CreateAsync( + CreateRequest(dataEncoding: new QualifiedName("Unsupported"))) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + error.StatusCode, + Is.EqualTo(StatusCodes.BadDataEncodingInvalid)); + Assert.That(item, Is.Null); + Assert.That(harness.OwnedMonitoredItemCount, Is.Zero); + Assert.That(harness.OwnedMonitoredNodeCount, Is.Zero); + }); + } + + private static MonitoredItemCreateRequest CreateRequest( + ExtensionObject filter = default, + string? indexRange = null, + NumericRange parsedIndexRange = default, + QualifiedName dataEncoding = default, + double samplingInterval = 100) + { + return new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = NodeId.Null, + AttributeId = Attributes.Value, + IndexRange = indexRange, + ParsedIndexRange = parsedIndexRange, + DataEncoding = dataEncoding + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, + SamplingInterval = samplingInterval, + QueueSize = 10, + DiscardOldest = true, + Filter = filter + } + }; + } + + private static async ValueTask DrainAsync() + { + for (int ii = 0; ii < 10; ii++) + { + await Task.Yield(); + } + } + + private static async ValueTask WaitForAsync( + Func condition, + string message, + int timeoutMilliseconds = 5000) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds( + timeoutMilliseconds); + while (!condition()) + { + if (DateTime.UtcNow >= deadline) + { + Assert.Fail(message); + } + await Task.Delay(10).ConfigureAwait(false); + } + } + + private sealed class PushMonitoredItem : MonitoredItem + { + public PushMonitoredItem(MonitoredItemFactoryContext context) + : base( + context.Server, + context.NodeManager, + context.Handle, + context.SubscriptionId, + context.MonitoredItemId, + context.Request.ItemToMonitor, + context.DiagnosticsMasks, + context.TimestampsToReturn, + context.Request.MonitoringMode, + context.Request.RequestedParameters.ClientHandle, + context.Filter, + context.Filter, + context.EuRange, + context.SamplingInterval, + context.QueueSize, + context.Request.RequestedParameters.DiscardOldest, + sourceSamplingInterval: 0, + context.CreateDurable) + { + } + } + + private sealed class MonitoredItemHarness : IDisposable + { + private MonitoredItemHarness( + TestMonitoredItemManager manager, + MonitoredItemQueueFactory queueFactory, + FakeTimeProvider time) + { + Manager = manager; + m_queueFactory = queueFactory; + Time = time; + } + + public TestMonitoredItemManager Manager { get; } + + public int OwnedMonitoredItemCount => Manager.OwnedMonitoredItemCount; + + public int OwnedMonitoredNodeCount => Manager.OwnedMonitoredNodeCount; + + public FakeTimeProvider Time { get; } + + public static async ValueTask CreateAsync( + Action configure, + bool useSamplingGroups = false) + { + var time = new FakeTimeProvider(); + Mock server = + CreateServer(time, out MonitoredItemQueueFactory queueFactory); + var manager = new TestMonitoredItemManager( + server.Object, + useSamplingGroups); + try + { + await manager.InitializeAsync(configure).ConfigureAwait(false); + return new MonitoredItemHarness(manager, queueFactory, time); + } + catch + { + manager.Dispose(); + queueFactory.Dispose(); + throw; + } + } + + public async ValueTask CreateAndGetErrorAsync( + MonitoredItemCreateRequest request) + { + (ServiceResult error, _) = await CreateAsync(request).ConfigureAwait(false); + return error; + } + + public async ValueTask<(ServiceResult Error, IMonitoredItem? Item)> CreateAsync( + MonitoredItemCreateRequest request) + { + request.ItemToMonitor.NodeId = Manager.VariableId; + var errors = new List { ServiceResult.Good }; + var filterErrors = new List { null! }; + var monitoredItems = new List { null! }; + + await Manager.CreateMonitoredItemsAsync( + NewContext(RequestType.CreateMonitoredItems), + subscriptionId: 1, + publishingInterval: 1000, + TimestampsToReturn.Both, + [request], + errors, + filterErrors, + monitoredItems, + createDurable: false, + new MonitoredItemIdFactory()).ConfigureAwait(false); + + return (errors[0], monitoredItems[0]); + } + + public async ValueTask ModifyAsync(IMonitoredItem item) + { + var request = new MonitoredItemModifyRequest + { + RequestedParameters = new MonitoringParameters + { + ClientHandle = 2, + SamplingInterval = 50, + QueueSize = 5, + DiscardOldest = true + } + }; + var errors = new List { ServiceResult.Good }; + var filterErrors = new List { null! }; + + await Manager.ModifyMonitoredItemsAsync( + NewContext(RequestType.ModifyMonitoredItems), + TimestampsToReturn.Both, + [item], + [request], + errors, + filterErrors).ConfigureAwait(false); + return errors[0]; + } + + public async ValueTask SetModeAsync( + IMonitoredItem item, + MonitoringMode monitoringMode) + { + var processed = new List { false }; + var errors = new List { ServiceResult.Good }; + await Manager.SetMonitoringModeAsync( + NewContext(RequestType.SetMonitoringMode), + monitoringMode, + [item], + processed, + errors).ConfigureAwait(false); + return errors[0]; + } + + public async ValueTask DeleteAsync(IMonitoredItem item) + { + var processed = new List { false }; + var errors = new List { ServiceResult.Good }; + await Manager.DeleteMonitoredItemsAsync( + NewContext(RequestType.DeleteMonitoredItems), + [item], + processed, + errors).ConfigureAwait(false); + return errors[0]; + } + + public void Dispose() + { + Manager.Dispose(); + m_queueFactory.Dispose(); + } + + private static OperationContext NewContext(RequestType requestType) + { + return new OperationContext( + new RequestHeader(), + null, + requestType, + RequestLifetime.None); + } + + private static Mock CreateServer( + FakeTimeProvider time, + out MonitoredItemQueueFactory queueFactory) + { + var server = new Mock(); + server.As() + .SetupGet(value => value.TimeProvider) + .Returns(time); + + var master = new Mock(); + var configuration = new Mock(); + var core = new Mock(); + var namespaceUris = new NamespaceTable(); + namespaceUris.Append(kNamespaceUri); + + server.SetupGet(value => value.NamespaceUris) + .Returns(namespaceUris); + server.SetupGet(value => value.ServerUris) + .Returns(new StringTable()); + server.SetupGet(value => value.TypeTree) + .Returns(new TypeTable(namespaceUris)); + server.SetupGet(value => value.Factory) + .Returns(EncodeableFactory.Create()); + server.SetupGet(value => value.NodeManager) + .Returns(master.Object); + server.SetupGet(value => value.CoreNodeManager) + .Returns(core.Object); + server.SetupGet(value => value.IsRunning) + .Returns(true); + master.SetupGet(value => value.ConfigurationNodeManager) + .Returns(configuration.Object); + master.SetupGet(value => value.CoreNodeManager) + .Returns(core.Object); + + var telemetry = new Mock(); + server.SetupGet(value => value.Telemetry) + .Returns(telemetry.Object); + queueFactory = new MonitoredItemQueueFactory(telemetry.Object); + server.SetupGet(value => value.MonitoredItemQueueFactory) + .Returns(queueFactory); + server.SetupGet(value => value.DefaultSystemContext) + .Returns(new ServerSystemContext(server.Object)); + return server; + } + + private readonly MonitoredItemQueueFactory m_queueFactory; + } + + private sealed class TestMonitoredItemManager : FluentNodeManagerBase + { + public TestMonitoredItemManager( + IServerInternal server, + bool useSamplingGroups) + : base( + server, + CreateConfiguration(), + useSamplingGroups, + server.Telemetry.CreateLogger(), + kNamespaceUri) + { + } + + public int OwnedMonitoredItemCount => MonitoredItems.Count; + + public int OwnedMonitoredNodeCount => MonitoredNodes.Count; + + public NodeId VariableId { get; private set; } + + public async ValueTask InitializeAsync(Action configure) + { + var variable = + BaseDataVariableState.With(null!); + variable.CreateAsPredefinedNode(SystemContext); + ushort namespaceIndex = NamespaceIndexes[0]; + variable.NodeId = VariableId = new NodeId("Value", namespaceIndex); + variable.BrowseName = new QualifiedName("Value", namespaceIndex); + variable.DisplayName = new LocalizedText("Value"); + variable.DataType = DataTypeIds.Int32; + variable.ValueRank = ValueRanks.Scalar; + variable.Value = 0; + variable.AccessLevel = AccessLevels.CurrentReadOrWrite; + variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite; + await AddPredefinedNodeAsync(variable).ConfigureAwait(false); + + NodeManagerBuilder builder = CreateFluentBuilder(namespaceIndex); + configure(builder); + builder.Seal(); + } + + private static ApplicationConfiguration CreateConfiguration() + { + return new ApplicationConfiguration + { + ServerConfiguration = new ServerConfiguration + { + MaxNotificationQueueSize = 100, + MaxDurableNotificationQueueSize = 200, + AvailableSamplingRates = [] + } + }; + } + } + + private const string kNamespaceUri = + "urn:opcfoundation:server:tests:fluent-monitored-item"; + } +} diff --git a/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs index e36c64dfa9..5ea22a218b 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs @@ -68,6 +68,18 @@ public void SimulationOnPlainBuilderThrowsBadConfigurationError() Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError)); } + [Test] + public void SimulationAcceptsTypedBuilderFacade() + { + using var h = SimulationHarness.Create(); + var facade = new Mock(); + facade.SetupGet(value => value.NodeManager) + .Returns(h.Builder.NodeManager); + + Assert.DoesNotThrow( + () => facade.Object.Simulation(TimeSpan.FromMilliseconds(10))); + } + [Test] public async Task OnTickFiresPeriodically() { diff --git a/tests/Opc.Ua.Server.Tests/Fluent/VirtualNodeBuilderTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/VirtualNodeBuilderTests.cs new file mode 100644 index 0000000000..e902d34cc7 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Fluent/VirtualNodeBuilderTests.cs @@ -0,0 +1,613 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Fluent; + +#nullable enable + +namespace Opc.Ua.Server.Tests.Fluent +{ + /// + /// Tests the on-demand node-family fluent surface. + /// + [TestFixture] + [Category("Fluent")] + public sealed class VirtualNodeBuilderTests + { + [Test] + public void ConfigurationIsRetainedByFluentManager() + { + var configuration = new ApplicationConfiguration(); + using var manager = new TestVirtualManager(configuration); + + Assert.That(manager.StartupConfiguration, Is.SameAs(configuration)); + } + + [Test] + public void ConfigurationlessConstructorExposesNull() + { + using var manager = new TestVirtualManager(); + + Assert.That(manager.StartupConfiguration, Is.Null); + } + + [Test] + public async Task VirtualNodeMaterializesWithRequestedIdAndHandlersAsync() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Temperature"); + NodeId childId = manager.VirtualId("Child"); + int resolverCalls = 0; + + manager.Builder.ResolveNodes( + id => id == requestedId, + (context, id, cancellationToken) => + { + resolverCalls++; + var variable = + BaseDataVariableState.With(null!); + variable.BrowseName = + new QualifiedName("Temperature", id.NamespaceIndex); + variable.DisplayName = new LocalizedText("Temperature"); + variable.DataType = DataTypeIds.Int32; + variable.ValueRank = ValueRanks.Scalar; + variable.AccessLevel = AccessLevels.CurrentRead; + variable.UserAccessLevel = AccessLevels.CurrentRead; + variable.Value = 0; + return new ValueTask(variable); + }) + .OnRead((ISystemContext context, NodeState node, ref Variant value) => + { + value = Variant.From(42); + return ServiceResult.Good; + }) + .OnCreateBrowser(( + context, + node, + view, + referenceType, + includeSubtypes, + browseDirection, + browseName, + additionalReferences, + internalOnly) => + { + var browser = new NodeBrowser( + context, + view, + referenceType, + includeSubtypes, + browseDirection, + browseName, + additionalReferences, + internalOnly); + browser.Add(ReferenceTypeIds.HasComponent, false, childId); + return browser; + }); + manager.Builder.Seal(); + + var cache = new Dictionary(); + (NodeHandle? handle, NodeState? node) = await manager + .ResolveAsync(requestedId, cache) + .ConfigureAwait(false); + + var value = new DataValue(); + ServiceResult readResult = node!.ReadAttribute( + manager.SystemContext, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + ref value); + using INodeBrowser browser = node.CreateBrowser( + manager.SystemContext, + view: null, + NodeId.Null, + includeSubtypes: true, + BrowseDirection.Forward, + QualifiedName.Null, + additionalReferences: null, + internalOnly: false); + IReference? reference = browser.Next(); + + Assert.Multiple(() => + { + Assert.That(handle, Is.Not.Null); + Assert.That(handle!.Validated, Is.True); + Assert.That(node.NodeId, Is.EqualTo(requestedId)); + Assert.That(ServiceResult.IsGood(readResult), Is.True); + Assert.That(value.WrappedValue.GetInt32(), Is.EqualTo(42)); + Assert.That(reference, Is.Not.Null); + Assert.That(reference!.TargetId, Is.EqualTo(new ExpandedNodeId(childId))); + Assert.That(resolverCalls, Is.EqualTo(1)); + Assert.That(manager.ContainsPredefined(requestedId), Is.False); + }); + } + + [Test] + public async Task OperationCacheReusesMaterializedNodeAsync() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Cached"); + int resolverCalls = 0; + + manager.Builder.ResolveNodes( + id => id == requestedId, + (context, id, cancellationToken) => + { + resolverCalls++; + return new ValueTask( + new BaseObjectState(null) + { + NodeId = id, + BrowseName = new QualifiedName("Cached", id.NamespaceIndex) + }); + }); + manager.Builder.Seal(); + + var cache = new Dictionary(); + (_, NodeState? first) = await manager.ResolveAsync(requestedId, cache) + .ConfigureAwait(false); + (_, NodeState? second) = await manager.ResolveAsync(requestedId, cache) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.SameAs(first)); + Assert.That(resolverCalls, Is.EqualTo(1)); + }); + } + + [Test] + public async Task MissingVirtualNodeIsCachedForOperationAsync() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Missing"); + int resolverCalls = 0; + + manager.Builder.ResolveNodes( + id => id == requestedId, + (context, id, cancellationToken) => + { + resolverCalls++; + return new ValueTask((NodeState?)null); + }); + manager.Builder.Seal(); + + var cache = new Dictionary(); + (_, NodeState? first) = await manager.ResolveAsync(requestedId, cache) + .ConfigureAwait(false); + (_, NodeState? second) = await manager.ResolveAsync(requestedId, cache) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + Assert.That(resolverCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void OverlappingVirtualFamiliesAreRejected() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Overlap"); + + manager.Builder.ResolveNodes( + id => id == requestedId, + static (context, id, cancellationToken) => + new ValueTask((NodeState?)null)); + manager.Builder.ResolveNodes( + id => id == requestedId, + static (context, id, cancellationToken) => + new ValueTask((NodeState?)null)); + manager.Builder.Seal(); + + ServiceResultException exception = Assert.ThrowsAsync( + async () => await manager.GetManagerHandleAsync(requestedId).ConfigureAwait(false))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo((uint)StatusCodes.BadConfigurationError)); + } + + [Test] + public void ConflictingMaterializedNodeIdIsRejected() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Requested"); + + manager.Builder.ResolveNodes( + id => id == requestedId, + (context, id, cancellationToken) => + new ValueTask( + new BaseObjectState(null) + { + NodeId = manager.VirtualId("Different") + })); + manager.Builder.Seal(); + + ServiceResultException exception = Assert.ThrowsAsync( + async () => await manager + .ResolveAsync(requestedId, new Dictionary()) + .ConfigureAwait(false))!; + + Assert.That(exception.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdInvalid)); + } + + [Test] + public void ResolverCancellationIsPropagated() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("Cancelled"); + + manager.Builder.ResolveNodes( + id => id == requestedId, + static (context, id, cancellationToken) => + new ValueTask( + Task.FromCanceled(cancellationToken))); + manager.Builder.Seal(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.CatchAsync( + async () => await manager + .ResolveAsync( + requestedId, + new Dictionary(), + cts.Token) + .ConfigureAwait(false)); + } + + [Test] + public async Task VirtualHistoryReadUsesFamilyHandlerAsync() + { + using var manager = new TestVirtualManager(); + NodeId requestedId = manager.VirtualId("History"); + int calls = 0; + bool releaseSeen = false; + + manager.Builder.ResolveNodes( + id => id == requestedId, + (context, id, cancellationToken) => + { + var variable = + BaseDataVariableState.With(null!); + variable.NodeId = id; + variable.BrowseName = + new QualifiedName("History", id.NamespaceIndex); + variable.DataType = DataTypeIds.Int32; + variable.ValueRank = ValueRanks.Scalar; + variable.AccessLevel = AccessLevels.HistoryRead; + variable.UserAccessLevel = AccessLevels.HistoryRead; + return new ValueTask(variable); + }) + .OnHistoryRead(( + context, + source, + details, + timestampsToReturn, + releaseContinuationPoints, + nodeToRead, + result) => + { + calls++; + releaseSeen |= releaseContinuationPoints; + result.StatusCode = StatusCodes.Good; + return ServiceResult.Good; + }); + manager.Builder.Seal(); + + var nodeToRead = new HistoryReadValueId + { + NodeId = requestedId + }; + var results = new List { null! }; + var errors = new List { StatusCodes.BadNodeIdUnknown }; + await manager.HistoryReadAsync( + new OperationContext( + new RequestHeader(), + null, + RequestType.HistoryRead, + RequestLifetime.None), + new ReadRawModifiedDetails + { + StartTime = DateTime.UtcNow.AddMinutes(-1), + EndTime = DateTime.UtcNow + }, + TimestampsToReturn.Both, + releaseContinuationPoints: false, + [nodeToRead], + results, + errors).ConfigureAwait(false); + + var releaseResults = new List { null! }; + var releaseErrors = new List + { + StatusCodes.BadNodeIdUnknown + }; + await manager.HistoryReadAsync( + new OperationContext( + new RequestHeader(), + null, + RequestType.HistoryRead, + RequestLifetime.None), + new ReadRawModifiedDetails(), + TimestampsToReturn.Both, + releaseContinuationPoints: true, + [new HistoryReadValueId { NodeId = requestedId }], + releaseResults, + releaseErrors).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(calls, Is.EqualTo(2)); + Assert.That(releaseSeen, Is.True); + Assert.That(ServiceResult.IsGood(errors[0]), Is.True); + Assert.That(ServiceResult.IsGood(releaseErrors[0]), Is.True); + Assert.That(results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + }); + } + + [Test] + public async Task VirtualPollingUsesIndependentStatePerNodeAsync() + { + using var manager = new TestVirtualManager(); + NodeId firstId = manager.VirtualId("First"); + NodeId secondId = manager.VirtualId("Second"); + var samples = new Dictionary(); + + manager.Builder.ResolveNodes( + id => id == firstId || id == secondId, + (context, id, cancellationToken) => + { + var variable = + BaseDataVariableState.With(null!); + variable.NodeId = id; + variable.BrowseName = + new QualifiedName(id == firstId ? "First" : "Second"); + variable.DataType = DataTypeIds.Int32; + variable.ValueRank = ValueRanks.Scalar; + variable.Value = 0; + return new ValueTask(variable); + }) + .PollWhileMonitored( + TimeSpan.FromHours(1), + (context, source, cancellationToken) => + { + samples.TryGetValue(source.NodeId, out int count); + samples[source.NodeId] = count + 1; + return new ValueTask(count + 1); + }); + manager.Builder.Seal(); + + var firstCache = new Dictionary(); + (NodeHandle? firstHandle, _) = await manager.ResolveAsync( + firstId, + firstCache).ConfigureAwait(false); + var secondCache = new Dictionary(); + (NodeHandle? secondHandle, _) = await manager.ResolveAsync( + secondId, + secondCache).ConfigureAwait(false); + Mock firstItem = + CreateMonitoredItem(1, firstId); + Mock secondItem = + CreateMonitoredItem(2, secondId); + firstItem.SetupGet(value => value.ManagerHandle) + .Returns(firstHandle!); + secondItem.SetupGet(value => value.ManagerHandle) + .Returns(secondHandle!); + + await manager.NotifyCreatedAsync( + firstHandle!, + firstItem.Object).ConfigureAwait(false); + await manager.NotifyCreatedAsync( + secondHandle!, + secondItem.Object).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(samples[firstId], Is.EqualTo(1)); + Assert.That(samples[secondId], Is.EqualTo(1)); + }); + + await manager.NotifyDeletedAsync( + firstHandle!, + firstItem.Object).ConfigureAwait(false); + await manager.NotifyDeletedAsync( + secondHandle!, + secondItem.Object).ConfigureAwait(false); + } + + [Test] + public async Task MultipleAttachedBuildersKeepTheirRegistrationsAsync() + { + using var manager = new TestVirtualManager(); + NodeId firstId = manager.VirtualId("FirstBuilder"); + NodeId secondId = manager.VirtualId("SecondBuilder"); + + manager.Builder.ResolveNodes( + id => id == firstId, + (context, id, cancellationToken) => + new ValueTask( + new BaseObjectState(null) + { + NodeId = id, + BrowseName = new QualifiedName("FirstBuilder") + })); + manager.Builder.Seal(); + + NodeManagerBuilder secondBuilder = manager.CreateAdditionalBuilder(); + secondBuilder.ResolveNodes( + id => id == secondId, + (context, id, cancellationToken) => + new ValueTask( + new BaseObjectState(null) + { + NodeId = id, + BrowseName = new QualifiedName("SecondBuilder") + })); + secondBuilder.Seal(); + + (_, NodeState? first) = await manager.ResolveAsync( + firstId, + new Dictionary()).ConfigureAwait(false); + (_, NodeState? second) = await manager.ResolveAsync( + secondId, + new Dictionary()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(first?.NodeId, Is.EqualTo(firstId)); + Assert.That(second?.NodeId, Is.EqualTo(secondId)); + }); + } + + private static Mock CreateMonitoredItem( + uint id, + NodeId nodeId) + { + var item = new Mock(); + item.SetupGet(value => value.Id).Returns(id); + item.SetupGet(value => value.NodeId).Returns(nodeId); + item.SetupGet(value => value.MonitoringMode) + .Returns(MonitoringMode.Reporting); + item.SetupGet(value => value.SamplingInterval).Returns(100); + return item; + } + + private sealed class TestVirtualManager : FluentNodeManagerBase + { + public TestVirtualManager() + : base(CreateMockServer(), kNamespaceUri) + { + Builder = CreateFluentBuilder(TestNamespaceIndex); + } + + public TestVirtualManager(ApplicationConfiguration configuration) + : base(CreateMockServer(), configuration, kNamespaceUri) + { + Builder = CreateFluentBuilder(TestNamespaceIndex); + } + + public ApplicationConfiguration? StartupConfiguration => Configuration; + + public NodeManagerBuilder Builder { get; } + + public ushort TestNamespaceIndex => NamespaceIndexes[0]; + + public NodeId VirtualId(string identifier) + { + return new NodeId(identifier, TestNamespaceIndex); + } + + public bool ContainsPredefined(NodeId nodeId) + { + return PredefinedNodes.ContainsKey(nodeId); + } + + public NodeManagerBuilder CreateAdditionalBuilder() + { + return CreateFluentBuilder(TestNamespaceIndex); + } + + public async ValueTask<(NodeHandle? Handle, NodeState? Node)> ResolveAsync( + NodeId nodeId, + IDictionary cache, + CancellationToken cancellationToken = default) + { + NodeHandle handle = await GetManagerHandleAsync( + SystemContext, + nodeId, + cache, + cancellationToken).ConfigureAwait(false); + if (handle == null) + { + return (null, null); + } + + NodeState node = await ValidateNodeAsync( + SystemContext, + handle, + cache, + cancellationToken).ConfigureAwait(false); + return (handle, node); + } + + public async ValueTask NotifyCreatedAsync( + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem) + { + OnMonitoredItemCreated(SystemContext, handle, monitoredItem); + await OnCreateMonitoredItemsCompleteAsync( + SystemContext, + [monitoredItem]).ConfigureAwait(false); + } + + public async ValueTask NotifyDeletedAsync( + NodeHandle handle, + ISampledDataChangeMonitoredItem monitoredItem) + { + await OnMonitoredItemDeletedAsync( + SystemContext, + handle, + monitoredItem).ConfigureAwait(false); + await OnDeleteMonitoredItemsCompleteAsync( + SystemContext, + [monitoredItem]).ConfigureAwait(false); + } + + private const string kNamespaceUri = "urn:test:virtual-nodes"; + + private static IServerInternal CreateMockServer() + { + var namespaceUris = new NamespaceTable(); + namespaceUris.Append(Ua.Namespaces.OpcUa); + + var telemetry = new Mock(); + var server = new Mock(); + server.SetupGet(value => value.NamespaceUris).Returns(namespaceUris); + server.SetupGet(value => value.Telemetry).Returns(telemetry.Object); + server.SetupGet(value => value.MessageContext) + .Returns(ServiceMessageContext.Create(telemetry.Object)); + server.SetupGet(value => value.DefaultSystemContext) + .Returns(new ServerSystemContext(server.Object)); + return server.Object; + } + } + } +} diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs index 5d23f858c5..b59fa57283 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -184,6 +184,19 @@ public void EmittedFluentBuilders_TopLevelInstanceWrapperLivesAtNamespaceScope() "Top-level wrappers must be declared at namespace scope (column 4)"); } + [Test] + public void EmittedFluentBuildersObjectWrapperUsesTypeDefinitionState() + { + string fb = GetFluentBuilders(); + + Assert.That( + fb, + Does.Match( + @"(?ms)internal\s+sealed\s+class\s+TestObjectBuilder\b.*?" + + @"INodeBuilder\s+__node;"), + "An object wrapper with a resolved TypeDefinition must use its concrete generated State type"); + } + [Test] public void EmittedFluentBuilders_MethodWrapperIsNestedInsideOwningObject() { diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs index 18423bae42..16d5af1d8a 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs @@ -81,12 +81,15 @@ public void EmittedNodeManager_HasRequiredStructuralMembers() Assert.That(mgr, Does.Contain(": global::Opc.Ua.Server.Fluent.FluentNodeManagerBase")); Assert.That(mgr, Does.Match(@"public\s+partial\s+class\s+\w+NodeManager")); - // Lifecycle overrides that wire the runtime fluent dispatcher. + // Node-manager lifecycle members emitted by the generated partial. Assert.That(mgr, Does.Contain("LoadPredefinedNodesAsync")); Assert.That(mgr, Does.Contain("CreateAddressSpaceAsync")); Assert.That(mgr, Does.Contain("AddPredefinedNodeAsync")); Assert.That(mgr, Does.Contain("RemovePredefinedNodeAsync")); - Assert.That(mgr, Does.Contain("OnMonitoredItemCreated")); + Assert.That( + mgr, + Does.Not.Contain("OnMonitoredItemCreated"), + "Monitored-item lifecycle forwarding is owned by FluentNodeManagerBase"); // The user code-behind hook + the runtime builder type. Assert.That(mgr, Does.Contain("partial void Configure(")); @@ -147,6 +150,25 @@ public void EmittedNodeManager_WithAdditionalNamespaceUris_ReportsThemAtConstruc "Factory NamespacesUris must include the additional namespace URI"); } + [Test] + public void EmittedNodeManagerInUnrelatedNamespaceUsesQualifiedModelComposer() + { + Dictionary files = GenerateForTestModel( + generateNodeManager: true, + nodeManagerNamespace: "Unrelated.Managers"); + + string mgr = files.Single( + kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value; + + Assert.That(mgr, Does.Contain("namespace Unrelated.Managers")); + Assert.That( + mgr, + Does.Match( + @"global::TestModel\.TestModelExtensions\.AddTestModel\(\s*" + + @"new global::Opc\.Ua\.NodeStateCollection\(\),\s*context\)"), + "LoadPredefinedNodesAsync must invoke the model composer as a fully-qualified static method"); + } + [Test] public void EmittedFactory_IsExtensible() { @@ -490,7 +512,8 @@ private static string ExtractInstanceMethodBody(string source, string methodName private static Dictionary GenerateForTestModel( bool generateNodeManager, - IReadOnlyList additionalNamespaceUris = null) + IReadOnlyList additionalNamespaceUris = null, + string nodeManagerNamespace = null) { const string designFile = "TestModel.xml"; ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); @@ -506,7 +529,8 @@ private static Dictionary GenerateForTestModel( Options = new DesignFileOptions { GenerateNodeManager = generateNodeManager, - NodeManagerAdditionalNamespaceUris = additionalNamespaceUris + NodeManagerAdditionalNamespaceUris = additionalNamespaceUris, + NodeManagerNamespace = nodeManagerNamespace } }, fileSystem, string.Empty, telemetry); diff --git a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs index 41cbcbe75f..620dc4516e 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs @@ -1088,6 +1088,281 @@ public partial class TypesNodeManager "the generated factory must advertise the additional namespace URI"); } + [Theory] + public void NodeManagerWithUnresolvedNamespaceUriReportsExpression( + LanguageVersion languageVersion) + { + const string bindingSource = + """ + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + [global::Opc.Ua.Server.Fluent.NodeManager( + NamespaceUri = GeneratedModel.NamespaceUri)] + public partial class TypesNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunMixedModelGenerator(languageVersion, bindingSource); + + Diagnostic[] unresolved = [.. diagnostics.Where(d => d.Id == "MODELGEN035")]; + Assert.That(unresolved, Has.Length.EqualTo(1)); + Assert.That( + unresolved[0].Location.SourceTree?.GetText() + .ToString(unresolved[0].Location.SourceSpan), + Is.EqualTo("GeneratedModel.NamespaceUri")); + Assert.That( + unresolved[0].GetMessage(CultureInfo.InvariantCulture), + Does.Contain("values generated in the same compilation are unavailable")); + Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty); + + string generated = string.Join( + "\n", + runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); + Assert.That(generated, Does.Not.Contain("class TypesNodeManager")); + } + + [Theory] + public void NodeManagerWithUnresolvedAdditionalNamespaceUrisReportsEachExpression( + LanguageVersion languageVersion) + { + const string bindingSource = + """ + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + [global::Opc.Ua.Server.Fluent.NodeManager( + NamespaceUri = "http://test.org/UA/CrossModel/Types", + AdditionalNamespaceUris = new[] + { + GeneratedModel.FirstNamespaceUri, + "http://test.org/UA/CrossModel/Types/Valid", + GeneratedModel.SecondNamespaceUri + })] + public partial class TypesNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunMixedModelGenerator(languageVersion, bindingSource); + + string[] expressions = [.. diagnostics + .Where(d => d.Id == "MODELGEN035") + .Select(d => d.Location.SourceTree?.GetText().ToString(d.Location.SourceSpan)) + .OrderBy(expression => expression, StringComparer.Ordinal)]; + string[] expectedExpressions = + { + "GeneratedModel.FirstNamespaceUri", + "GeneratedModel.SecondNamespaceUri" + }; + Assert.That(expressions, Is.EqualTo(expectedExpressions)); + Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Is.Empty); + + string generated = string.Join( + "\n", + runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); + Assert.That(generated, Does.Not.Contain("class TypesNodeManager")); + } + + [Theory] + public void NodeManagerWithSourceConstantsGeneratesBinding( + LanguageVersion languageVersion) + { + const string bindingSource = + """ + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + public static class ModelUris + { + public const string Types = "http://test.org/UA/CrossModel/Types"; + public const string Instances = "http://test.org/UA/CrossModel/Types/Instance"; + } + [global::Opc.Ua.Server.Fluent.NodeManager( + NamespaceUri = ModelUris.Types, + AdditionalNamespaceUris = new[] { ModelUris.Instances })] + public partial class TypesNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunMixedModelGenerator(languageVersion, bindingSource); + + Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); + string generated = string.Join( + "\n", + runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); + Assert.That(generated, Does.Contain("class TypesNodeManager")); + Assert.That( + generated, + Does.Contain("\"http://test.org/UA/CrossModel/Types/Instance\"")); + } + + [Theory] + public void NodeManagerWithReferencedConstantsGeneratesBinding( + LanguageVersion languageVersion) + { + CSharpCompilation constantsCompilation = OptimizationLevel.Release + .CreateCompilation("NodeManagerConstants") + .AddCode( + new Dictionary + { + ["ModelUris.cs"] = + """ + namespace Shared; + public static class ModelUris + { + public const string Types = + "http://test.org/UA/CrossModel/Types"; + public const string Instances = + "http://test.org/UA/CrossModel/Types/Referenced"; + } + """ + }, + languageVersion); + MetadataReference constantsReference = constantsCompilation.ToMetadataReference(); + const string bindingSource = + """ + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + [global::Opc.Ua.Server.Fluent.NodeManager( + NamespaceUri = global::Shared.ModelUris.Types, + AdditionalNamespaceUris = new[] { global::Shared.ModelUris.Instances })] + public partial class TypesNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunMixedModelGenerator(languageVersion, bindingSource, constantsReference); + + Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); + string generated = string.Join( + "\n", + runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); + Assert.That(generated, Does.Contain("class TypesNodeManager")); + Assert.That( + generated, + Does.Contain("\"http://test.org/UA/CrossModel/Types/Referenced\"")); + } + + [TestCase("null")] + [TestCase("new string[] { }")] + [TestCase("new string[] { null, \"\" }")] + public void NodeManagerWithEmptyAdditionalNamespaceUrisGeneratesBinding( + string additionalNamespaceUris) + { + string bindingSource = + $$""" + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + [global::Opc.Ua.Server.Fluent.NodeManager( + NamespaceUri = "http://test.org/UA/CrossModel/Types", + AdditionalNamespaceUris = {{additionalNamespaceUris}})] + public partial class TypesNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunMixedModelGenerator(LanguageVersion.CSharp13, bindingSource); + + Assert.That(diagnostics.Where(d => d.Id is "MODELGEN010" or "MODELGEN035"), Is.Empty); + string generated = string.Join( + "\n", + runResult.Results[0].GeneratedSources.Select(s => s.SourceText.ToString())); + Assert.That(generated, Does.Contain("class TypesNodeManager")); + } + + [TestCase("null")] + [TestCase("\"\"")] + public void NodeManagerWithEmptyNamespaceUriRetainsSelectorlessBehavior( + string namespaceUri) + { + string bindingSource = + $$""" + namespace Opc.Ua.Server.Fluent + { + public sealed class NodeManagerAttribute : global::System.Attribute + { + public string NamespaceUri { get; set; } + public string Design { get; set; } + public bool GenerateFactory { get; set; } + public string[] AdditionalNamespaceUris { get; set; } + } + } + namespace CrossModelConsumer + { + [global::Opc.Ua.Server.Fluent.NodeManager(NamespaceUri = {{namespaceUri}})] + public partial class AmbiguousNodeManager + { + } + } + """; + + (ImmutableArray diagnostics, _) = + RunMixedModelGenerator(LanguageVersion.CSharp13, bindingSource); + + Assert.That(diagnostics.Where(d => d.Id == "MODELGEN035"), Is.Empty); + Assert.That(diagnostics.Where(d => d.Id == "MODELGEN010"), Has.Exactly(1).Items); + } + [Theory] public void NodeManagerBoundToModelDesignInstancesIsNotReportedUnmatchedWithNodeSet2( LanguageVersion languageVersion) @@ -1184,7 +1459,10 @@ public partial class AmbiguousNodeManager /// present in this model-only test compilation. /// private static (ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) - RunMixedModelGenerator(LanguageVersion languageVersion, string bindingSource) + RunMixedModelGenerator( + LanguageVersion languageVersion, + string bindingSource, + MetadataReference additionalReference = null) { var generator = new ModelSourceGenerator(); @@ -1193,6 +1471,10 @@ private static (ImmutableArray Diagnostics, GeneratorDriverRunResult { ["NodeManagerBinding.cs"] = bindingSource }.WithOpcUaGeneratedStack(), languageVersion); + if (additionalReference != null) + { + compilation = compilation.AddReferences(additionalReference); + } var options = new AnalyzerOptionsProvider( new Dictionary diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index 750e912442..67863ebf49 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -1126,7 +1126,7 @@ private string ResolveChildStateClr(InstanceDesign child) System.Xml.XmlQualifiedName typeDef = child?.TypeDefinition; if (typeDef == null || string.IsNullOrEmpty(typeDef.Name)) { - return ResolveStateClrType(child); + return ResolveFallbackStateClrType(child); } if (child.TypeDefinitionNode is ObjectTypeDesign objectType) { @@ -1141,6 +1141,23 @@ private string ResolveChildStateClr(InstanceDesign child) : "global::" + prefix + "." + stateName; } + private static string ResolveFallbackStateClrType(NodeDesign node) + { + if (node is ObjectDesign) + { + return "global::Opc.Ua.BaseObjectState"; + } + if (node is MethodDesign) + { + return "global::Opc.Ua.MethodState"; + } + if (node is VariableDesign) + { + return "global::Opc.Ua.BaseDataVariableState"; + } + return "global::Opc.Ua.NodeState"; + } + /// /// Maps a model namespace URI (e.g. http://opcfoundation.org/UA/Pumps/) /// to the C# namespace prefix declared in the model's @@ -1837,22 +1854,11 @@ private static bool QualifiesAsEventNotifier(NodeDesign node) /// private string ResolveStateClrType(NodeDesign node) { - // For object instances, use BaseObjectState as the lowest common - // denominator. The user can call .Builder.As<TConcrete>() to - // narrow. - if (node is ObjectDesign) + if (node is ObjectDesign objectDesign) { - return "global::Opc.Ua.BaseObjectState"; + return ResolveChildStateClr(objectDesign); } - if (node is MethodDesign) - { - return "global::Opc.Ua.MethodState"; - } - if (node is VariableDesign) - { - return "global::Opc.Ua.BaseDataVariableState"; - } - return "global::Opc.Ua.NodeState"; + return ResolveFallbackStateClrType(node); } /// diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs index aa4f721246..d176dce5e0 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs @@ -118,7 +118,7 @@ public IEnumerable Emit() var resources = new List(2) { - EmitNodeManager(targetNamespace, targetClass, typeStem, nsUriSymbol, fileStem) + EmitNodeManager(nsPrefix, targetNamespace, targetClass, typeStem, nsUriSymbol, fileStem) }; if (EmitFactory) { @@ -128,6 +128,7 @@ public IEnumerable Emit() } private TextFileResource EmitNodeManager( + string modelNamespace, string targetNamespace, string targetClass, string typeStem, @@ -142,6 +143,7 @@ private TextFileResource EmitNodeManager( using var templateWriter = new TemplateWriter(writer); var template = new Template(templateWriter, NodeManagerTemplates.File); template.AddReplacement(Tokens.NamespacePrefix, targetNamespace); + template.AddReplacement(Tokens.Prefix, modelNamespace); template.AddReplacement(Tokens.Namespace, typeStem); template.AddReplacement(Tokens.NodeManagerClassName, targetClass); template.AddReplacement(Tokens.NamespaceUri, nsUriSymbol); diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index f9fa332d81..3a1e582700 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -93,7 +93,9 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Flu global::System.Threading.CancellationToken cancellationToken = default) { return new global::System.Threading.Tasks.ValueTask( - new global::Opc.Ua.NodeStateCollection().Add{{Tokens.Namespace}}(context)); + global::{{Tokens.Prefix}}.{{Tokens.Namespace}}Extensions.Add{{Tokens.Namespace}}( + new global::Opc.Ua.NodeStateCollection(), + context)); } /// @@ -163,19 +165,6 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Flu await base.RemovePredefinedNodeAsync(context, node, referencesToRemove, cancellationToken).ConfigureAwait(false); } - /// - protected override void OnMonitoredItemCreated( - global::Opc.Ua.Server.ServerSystemContext context, - global::Opc.Ua.Server.NodeHandle handle, - global::Opc.Ua.Server.ISampledDataChangeMonitoredItem monitoredItem) - { - base.OnMonitoredItemCreated(context, handle, monitoredItem); - if (__m_builder is { } __b && handle?.Node is { } __node) - { - __b.Dispatcher.NotifyMonitoredItemCreated(context, __node, monitoredItem); - } - } - private global::Opc.Ua.NodeState __FindRootByBrowseName(global::Opc.Ua.QualifiedName browseName) { if (browseName == null) diff --git a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md index 26d833f083..5b81a807e3 100644 --- a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md @@ -28,3 +28,4 @@ MODELGEN031 | ModelSourceGenerator | Error | WoT model could not be converted to MODELGEN032 | ModelSourceGenerator | Warning | WoT model conversion produced a warning MODELGEN033 | ModelSourceGenerator | Info | WoT model conversion note MODELGEN034 | ModelSourceGenerator | Error | WoT model virtual NodeSet2 path collides with another input +MODELGEN035 | ModelSourceGenerator | Error | [NodeManager] namespace URI could not be resolved diff --git a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs index f790d78040..d3f7b956f0 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs @@ -142,6 +142,27 @@ public void Emit(CancellationToken cancellationToken) { continue; } + if (!discovery.InvalidExpressions.IsDefaultOrEmpty) + { + string targetType = string.IsNullOrEmpty( + discovery.Binding.TargetNamespace) + ? discovery.Binding.TargetClassName + : discovery.Binding.TargetNamespace + + "." + + discovery.Binding.TargetClassName; + foreach (NodeManagerAttributeExpressionError error in + discovery.InvalidExpressions) + { + m_context.ReportDiagnostic( + Diagnostic.Create( + SourceGenerator.NodeManagerArgumentUnresolved, + error.Location, + error.ArgumentName, + error.Expression, + targetType)); + } + continue; + } if (!discovery.IsPartial) { m_context.ReportDiagnostic( diff --git a/tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs b/tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs index b3f3066986..bddee223a4 100644 --- a/tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs +++ b/tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs @@ -27,6 +27,8 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; using Microsoft.CodeAnalysis; @@ -59,6 +61,11 @@ internal sealed record class NodeManagerAttributeDiscovery /// public bool IsPartial { get; init; } + /// + /// Attribute expressions that Roslyn could not bind to constants. + /// + public ImmutableArray InvalidExpressions { get; init; } + /// /// Predicate used by . /// @@ -82,6 +89,8 @@ public static NodeManagerAttributeDiscovery Create( string design = attr.GetValue(nameof(NodeManagerAttributeBinding.Design)); string[] additionalNamespaceUris = attr.GetStringArray( nameof(NodeManagerAttributeBinding.AdditionalNamespaceUris)); + ImmutableArray invalidExpressions = + GetInvalidExpressions(attr, context.SemanticModel, cancellationToken); bool generateFactory = attr == null || !attr.NamedArguments .Any(p => p.Key == nameof(NodeManagerAttributeBinding.GenerateFactory) && @@ -110,8 +119,130 @@ is TypeDeclarationSyntax tds && AdditionalNamespaceUris = additionalNamespaceUris }, Location = location, - IsPartial = isPartial + IsPartial = isPartial, + InvalidExpressions = invalidExpressions }; } + + private static ImmutableArray GetInvalidExpressions( + AttributeData attribute, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (attribute?.ApplicationSyntaxReference?.GetSyntax(cancellationToken) is not + AttributeSyntax attributeSyntax) + { + return []; + } + + var errors = ImmutableArray.CreateBuilder(); + AddInvalidExpression( + errors, + attribute, + attributeSyntax, + semanticModel, + nameof(NodeManagerAttributeBinding.NamespaceUri), + isArray: false, + cancellationToken); + AddInvalidExpression( + errors, + attribute, + attributeSyntax, + semanticModel, + nameof(NodeManagerAttributeBinding.AdditionalNamespaceUris), + isArray: true, + cancellationToken); + return errors.ToImmutable(); + } + + private static void AddInvalidExpression( + ImmutableArray.Builder errors, + AttributeData attribute, + AttributeSyntax attributeSyntax, + SemanticModel semanticModel, + string argumentName, + bool isArray, + CancellationToken cancellationToken) + { + KeyValuePair namedArgument = attribute.NamedArguments + .FirstOrDefault(argument => argument.Key == argumentName); + if (namedArgument.Key == null) + { + return; + } + TypedConstant value = namedArgument.Value; + bool hasError = value.Kind == TypedConstantKind.Error || + (isArray && + value.Kind == TypedConstantKind.Array && + !value.IsNull && + value.Values.Any(element => element.Kind == TypedConstantKind.Error)); + if (!hasError) + { + return; + } + + AttributeArgumentSyntax argumentSyntax = attributeSyntax.ArgumentList?.Arguments + .FirstOrDefault(argument => + argument.NameEquals?.Name.Identifier.ValueText == argumentName); + if (argumentSyntax == null) + { + return; + } + + if (isArray) + { + ExpressionSyntax[] elementExpressions = + GetArrayElementExpressions(argumentSyntax.Expression); + if (elementExpressions.Length > 0) + { + int initialCount = errors.Count; + for (int ii = 0; ii < elementExpressions.Length; ii++) + { + ExpressionSyntax elementExpression = elementExpressions[ii]; + bool isInvalid = namedArgument.Value.Kind == TypedConstantKind.Array && + ii < namedArgument.Value.Values.Length + ? namedArgument.Value.Values[ii].Kind == TypedConstantKind.Error + : !semanticModel.GetConstantValue( + elementExpression, + cancellationToken).HasValue; + if (isInvalid) + { + errors.Add(new NodeManagerAttributeExpressionError( + argumentName, + elementExpression.ToString(), + elementExpression.GetLocation())); + } + } + if (errors.Count > initialCount) + { + return; + } + } + } + + errors.Add(new NodeManagerAttributeExpressionError( + argumentName, + argumentSyntax.Expression.ToString(), + argumentSyntax.Expression.GetLocation())); + } + + private static ExpressionSyntax[] GetArrayElementExpressions(ExpressionSyntax expression) + { + SeparatedSyntaxList? expressions = expression switch + { + ArrayCreationExpressionSyntax array => array.Initializer?.Expressions, + ImplicitArrayCreationExpressionSyntax array => array.Initializer.Expressions, + _ => null + }; + return expressions.HasValue ? [.. expressions.Value] : []; + } } + + /// + /// An unresolved constant expression in a [NodeManager] attribute. + /// + internal sealed record class NodeManagerAttributeExpressionError( + string ArgumentName, + string Expression, + Location Location); } diff --git a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs index 16750ea98b..83d0667255 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs @@ -354,6 +354,23 @@ internal static partial class SourceGenerator helpLinkUri: "www.opcfoundation.org", customTags: ["opcua"]); + /// + /// A [NodeManager] namespace URI expression could not be + /// resolved before source generation. + /// + public static readonly DiagnosticDescriptor NodeManagerArgumentUnresolved = new( + id: "MODELGEN035", + title: "[NodeManager] namespace URI could not be resolved", + messageFormat: (LocalizableString)("The {0} expression '{1}' on [NodeManager] " + + "type '{2}' could not be resolved during source generation. Use a string " + + "literal or a const declared in source or a referenced assembly; values " + + "generated in the same compilation are unavailable."), + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + /// /// Get diagnostic descriptor for event id ///