From a3ff604403c7291650437525f390e8ebfe3575d5 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 08:12:21 +0200 Subject: [PATCH 1/7] Materialize WoT Thing Descriptions into the server address space Adds the materialization runtime that turns registered WoT Thing Descriptions into live OPC UA nodes: a dependency graph over the registered documents, a coordinator that drives resolution and projection, and a binding runtime that attaches protocol channels to the projected variables. Projection runs through the NodeManager lifecycle rather than mutating a running address space in place, so a refresh either commits a complete new generation or leaves the previous one untouched. The resolver, converter, contributor and target variable seams are interfaces so a host can substitute its own document sources. Also adds the registry NodeManager that exposes the registry itself in the address space, and the hosting extensions that register the whole runtime with dependency injection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- src/Opc.Ua.WotCon.Server/EventIds.cs | 1 + ...OpcUaWotRegistryServerBuilderExtensions.cs | 200 +++ .../Materialization/IWotDocumentConverter.cs | 180 +++ .../Materialization/IWotNodeSetContributor.cs | 79 ++ .../Materialization/IWotNodeSetResolver.cs | 80 ++ .../IWotProjectionBindingRuntimeFactory.cs | 83 ++ .../Materialization/IWotProjectionHost.cs | 262 ++++ .../IWotTargetVariableResolver.cs | 166 +++ .../LifecycleWotProjectionHost.cs | 219 +++ .../Materialization/WotBindingChannelSlot.cs | 152 ++ .../Materialization/WotDependencyGraph.cs | 565 ++++++++ .../WotMaterializationCoordinator.cs | 1239 +++++++++++++++++ .../WotMaterializationTypes.cs | 251 ++++ .../WotProjectionBindingRuntime.cs | 546 ++++++++ .../WotProjectionBindingRuntimeFactory.cs | 107 ++ .../Materialization/WotRefreshArguments.cs | 362 +++++ .../WotStructuredFieldBinding.cs | 275 ++++ .../WotStructuredGroupState.cs | 239 ++++ .../Opc.Ua.WotCon.Server.csproj | 27 +- .../WotRegistryNodeManager.cs | 587 ++++++++ .../WotRegistryNodeManagerFactory.cs | 90 ++ .../WotRegistryProjection.cs | 1160 +++++++++++++++ .../WotRegistryServerOptions.cs | 112 ++ 23 files changed, 6974 insertions(+), 8 deletions(-) create mode 100644 src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs create mode 100644 src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs create mode 100644 src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs create mode 100644 src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs create mode 100644 src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs create mode 100644 src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs diff --git a/src/Opc.Ua.WotCon.Server/EventIds.cs b/src/Opc.Ua.WotCon.Server/EventIds.cs index 9a0f746df9..ca03f5b0eb 100644 --- a/src/Opc.Ua.WotCon.Server/EventIds.cs +++ b/src/Opc.Ua.WotCon.Server/EventIds.cs @@ -46,5 +46,6 @@ internal static class WotConServerEventIds public const int AssetRegistry = 0; public const int WotAssetFileManager = 40; public const int WotConnectivityNodeManager = 50; + public const int WotRegistryNodeManager = 60; } } diff --git a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs new file mode 100644 index 0000000000..fdc55dad6a --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs @@ -0,0 +1,200 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Opc.Ua; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry.Server; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that host the WoT Connectivity 1.1 + /// registry (WoTRegistry) on the OPC UA server registered via + /// .AddServer(...). The registry service, materialization coordinator, + /// binder registry and projection host are registered as singletons; the + /// stable is attached at server start. + /// + public static class OpcUaWotRegistryServerBuilderExtensions + { + /// + /// Default configuration section for the registry options. + /// + public const string DefaultConfigurationSection = "OpcUa:WotConRegistry:Server"; + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager configured by + /// . + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure is not null) + { + builder.Services.AddOptions().Configure(configure); + } + else + { + builder.Services.AddOptions(); + } + RegisterCommonServices(builder.Services); + return builder; + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfiguration configuration) + { + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + return builder.AddWotRegistryServer( + configuration.GetSection(DefaultConfigurationSection)); + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfigurationSection section) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (section is null) + { + throw new ArgumentNullException(nameof(section)); + } + builder.Services.AddOptions().Bind(section); + RegisterCommonServices(builder.Services); + return builder; + } + + private static void RegisterCommonServices(IServiceCollection services) + { + services.TryAddSingleton(sp => + sp.GetRequiredService>().Value + ?? new WotRegistryServerOptions()); + + services.EnsureWotBinderRegistry(); + + services.TryAddSingleton(new WotTargetVariableResolver()); + + services.TryAddSingleton(sp => + new WotProjectionBindingRuntimeFactory( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + { + WotRegistryServerOptions options = + sp.GetRequiredService(); + // A store registered in DI wins over one set on the options, so a deployment can + // supply a shared store the same way the xRegistry server does. + IXRegistryResourceStore? resourceStore = + sp.GetService() ?? options.ResourceStore; + IWotRegistryStore store = string.IsNullOrEmpty(options.StorageFolder) + ? new InMemoryWotRegistryStore() + : resourceStore is null + ? new FileWotRegistryStore(options.StorageFolder!) + : new FileWotRegistryStore(options.StorageFolder!, resourceStore); + return new WotRegistryService(store, options.Bounds); + }); + + services.TryAddSingleton(sp => + new LifecycleWotProjectionHost( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + { + WotRegistryServerOptions options = + sp.GetRequiredService(); + var converterOptions = new WotNodeSetConverterOptions + { + MaxJsonDocumentSize = options.Bounds.MaxDocumentBytes, + MaxResolverDocumentBytes = options.Bounds.MaxDocumentBytes + }; + return new WotMaterializationCoordinator( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + converterOptions, + // Both seams are optional: a deployment that registers neither gets exactly + // the previous behaviour. Registering an IWotDocumentConverter replaces the + // Thing Description to NodeSet conversion; registering IWotNodeSetContributor + // instances adds nodes (typically controller-specific StructureType DataTypes) + // to the converted NodeSet before it is materialized. + sp.GetService(), + sp.GetServices(), + sp.GetService()); + }); + + services.TryAddSingleton(sp => + new WotRegistryNodeManagerFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => + new OpcUaServerNodeManagerRegistration( + sp.GetRequiredService())); + + services.AddOpcUa(); + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs new file mode 100644 index 0000000000..2f1b10cf29 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The result of converting one registry document to a NodeSet2 model. + /// + public sealed class WotConversionOutput + { + /// + /// Initializes a successful or failed conversion output. + /// + public WotConversionOutput( + UANodeSet? nodeSet, + ImmutableArray errors, + ExpandedNodeId rootNodeId = default) + { + NodeSet = nodeSet; + Errors = errors.IsDefault ? [] : errors; + RootNodeId = rootNodeId; + } + + /// + /// Gets the produced NodeSet2, or null on failure. + /// + public UANodeSet? NodeSet { get; } + + /// + /// Gets the conversion error messages. + /// + public ImmutableArray Errors { get; } + + /// + /// Gets the root node of the projection (the type a Thing Model + /// materializes or the top-level instance a Thing Description projects), + /// as an absolute whose namespace URI is + /// resolved from the produced NodeSet, or ExpandedNodeId.Null + /// when the document has no identifiable root. + /// + public ExpandedNodeId RootNodeId { get; } + + /// + /// Gets whether the conversion succeeded. + /// + public bool Succeeded => NodeSet is not null && Errors.IsEmpty; + + /// + /// Creates a successful output. + /// + public static WotConversionOutput Success(UANodeSet nodeSet) + { + return new WotConversionOutput( + nodeSet, + [], + WotNodeSetConverter.TrySelectProjectionRoot(nodeSet)); + } + + /// + /// Creates a failed output. + /// + public static WotConversionOutput Failure(params string[] errors) + { + return new WotConversionOutput(null, [.. errors]); + } + } + + /// + /// Converts a stored registry document to a NodeSet2 model. The default + /// implementation delegates to and resolves + /// TM references from the registry snapshot; a test double can substitute a + /// deterministic conversion. + /// + public interface IWotDocumentConverter + { + /// + /// Converts a resource's default document to a NodeSet2 model. + /// + ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken); + } + + /// + /// The production converter over . + /// + public sealed class WotNodeSetDocumentConverter : IWotDocumentConverter + { + /// + /// Initializes a new converter with the supplied options. + /// + public WotNodeSetDocumentConverter(WotNodeSetConverterOptions? options = null) + { + m_options = options ?? new WotNodeSetConverterOptions(); + } + + /// + public async ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + try + { + using var document = WotDocument.Parse(content, m_options); + var resolver = new SnapshotThingResolver(snapshot); + // One resolution context per top-level conversion, seeded from + // the configured converter options, so depth/document/byte + // bounds and cycle detection apply across every link resolved + // while converting this resource. + var resolution = new WotResolutionContext(m_options.ToResolverOptions()); + WotConversionResult result = await WotNodeSetConverter.ToNodeSetResultAsync( + document, m_options, resolver, resolution, cancellationToken).ConfigureAwait(false); + ImmutableArray.Builder errors = ImmutableArray.CreateBuilder(); + foreach (WotDiagnostic diagnostic in result.Diagnostics) + { + if (diagnostic.Severity == WotDiagnosticSeverity.Error) + { + errors.Add(diagnostic.ToString()); + } + } + if (result.Value is null && errors.Count == 0) + { + errors.Add("The document could not be converted to a NodeSet."); + } + if (errors.Count != 0 || result.Value is null) + { + return new WotConversionOutput(null, errors.ToImmutable()); + } + return new WotConversionOutput( + result.Value, + [], + WotNodeSetConverter.TrySelectProjectionRoot(result.Value)); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or FormatException) + { + return WotConversionOutput.Failure(ex.Message); + } + } + + private readonly WotNodeSetConverterOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs new file mode 100644 index 0000000000..ae29f3ef95 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs @@ -0,0 +1,79 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Contributes additional nodes to a converted document before it is materialized into the + /// AddressSpace — typically custom StructureType DataTypes that have no NodeSet to + /// import because they are specific to one controller program. + /// + /// + /// + /// Many industrial protocols expose user-defined types — Rockwell / Studio 5000 UDTs, TIA + /// Portal PLC data types, Beckhoff TwinCAT structured types — that must be generated from the + /// controller's own symbol table at onboarding time. A contributor runs once per resource, + /// after the Thing Description has been converted to a NodeSet and before any variable is + /// created, which is the point at which such a DataType has to exist: a + /// uav:mapByFieldPath mapping into a structured type can only resolve once that type is + /// registered. + /// + /// + /// A document that can already express its types declaratively does not need this seam — the + /// native projection (uav:NodeModel) carries DataType nodes with their + /// DataTypeDefinition directly. This interface is for types that can only be discovered + /// programmatically. + /// + /// + /// Contributors are resolved from dependency injection; registering none leaves conversion + /// unchanged. Implementations must be safe for concurrent calls across resources. + /// + /// + public interface IWotNodeSetContributor + { + /// + /// Contributes nodes for to . + /// + /// The registry resource being materialized. + /// + /// The converted NodeSet, mutated in place. A contributor adds nodes; it must not remove or + /// rewrite nodes produced by the conversion. + /// + /// The cancellation token. + ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs new file mode 100644 index 0000000000..5dbf307ac0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs @@ -0,0 +1,80 @@ +/* ======================================================================== + * 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Resolves the NodeSet2 for an OPC UA namespace a Thing Description depends on but the server + /// does not already know. + /// + /// + /// + /// Thing Descriptions are uploaded at run time through the standard WoTAssetFileType + /// upload, so the set of namespaces a server will be asked to serve is not known at start-up + /// and static pre-loading is not sufficient. A resolver closes that gap: it is consulted for + /// each unknown namespace, and what it returns is loaded through the server's existing runtime + /// NodeSet support. + /// + /// + /// A document that carries its own model does not need a resolver at all — the + /// uav:nodeSet envelope embeds the NodeSet2 in the Thing Description itself. + /// + /// + /// Resolution is recursive: a resolved NodeSet's own dependencies are resolved the same way. + /// A namespace that cannot be resolved is reported as a diagnostic rather than failing the + /// whole onboarding, so an operator can see exactly what is missing. + /// + /// + /// No implementation ships with the library: resolving a namespace means reaching out to some + /// catalogue — a UA Cloud Library instance, a corporate model repository, a folder on disk — + /// which is a deployment decision. Registering none leaves behaviour unchanged. + /// Implementations must be safe for concurrent calls. + /// + /// + public interface IWotNodeSetResolver + { + /// + /// Attempts to resolve the NodeSet2 for . + /// + /// The namespace URI to resolve. + /// The cancellation token. + /// + /// A readable stream positioned at the start of the NodeSet2 XML, which the caller + /// disposes; or null when this resolver does not know the namespace. Returning + /// null is the expected way to decline — it is not an error. + /// + ValueTask TryResolveAsync( + string namespaceUri, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs new file mode 100644 index 0000000000..97254f4b4f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.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; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Builds the per-generation OPC UA target-mapping binding runtime for a + /// runtime NodeSet. Injected into , + /// which invokes it from + /// once the generation's NodeSet2 content has been imported, so target + /// mappings are resolved against the freshly materialized predefined nodes. + /// The returned (if any) is owned by that + /// NodeSet generation and is disposed with it. + /// + public interface IWotProjectionBindingRuntimeFactory + { + /// + /// Wires the OPC UA target-mapping bindings declared by + /// onto the freshly imported predefined + /// nodes exposed by . + /// + /// + /// The fluent builder for the node manager generation being activated. + /// + /// + /// The prepared binding plans for the projected closure. Forms without + /// a target mapping, and non-executable forms, are ignored. + /// + /// The cancellation token. + /// + /// The generation-owned binding runtime, or null when no target + /// mapping was wired (for example an empty ). + /// + /// + /// A target mapping is missing, malformed, ambiguous, resolves to the + /// wrong node class, mismatches its declared target type, conflicts + /// with another mapping to the same target, duplicates a read or write + /// mapping, or is declared on an unsupported operation. Structured + /// (uav:mapByFieldPath) validation that depends on the + /// target's structure type being registered — the structure lookup, + /// root instance validation, and per-field path resolution — is + /// deferred past this call; see the class remarks on + /// for why, and for how a + /// first structured read or write fails deterministically instead. + /// + ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs new file mode 100644 index 0000000000..aef9b80401 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs @@ -0,0 +1,262 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Selects how the previous projection generation is retired after a + /// successful replacement. + /// + public enum WotProjectionRetirementPolicy + { + /// + /// Keep the previous generation alive until its monitored items and + /// requests drain. + /// + Graceful, + + /// + /// Invalidate its monitored items with BadNodeIdUnknown and dispose the + /// previous generation without waiting for drain. + /// + Immediate + } + + /// + /// One NodeSet2 document loaded as a runtime NodeManager source. A projection + /// closure produces one or more of these (TM type NodeSets loaded before the + /// dependent TD instance NodeSet). + /// + public sealed class WotProjectionSource + { + /// + /// Initializes a new projection source. + /// + public WotProjectionSource( + string name, + ImmutableArray modelNamespaceUris, + byte[] nodeSetXml) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + ModelNamespaceUris = modelNamespaceUris.IsDefault + ? [] : modelNamespaceUris; + NodeSetXml = nodeSetXml ?? throw new ArgumentNullException(nameof(nodeSetXml)); + } + + /// + /// Gets the diagnostic source name. + /// + public string Name { get; } + + /// + /// Gets the model namespace URIs this source owns. + /// + public ImmutableArray ModelNamespaceUris { get; } + + /// + /// Gets the serialized NodeSet2 XML bytes. + /// + public byte[] NodeSetXml { get; } + } + + /// + /// The full set of NodeSet2 sources for one projection closure to be added + /// or shadow-reloaded as a single runtime NodeManager. + /// + public sealed class WotProjectionDocument + { + /// + /// Initializes a new projection document. + /// + public WotProjectionDocument( + string closureKey, + ImmutableArray sources) + : this(closureKey, sources, []) + { + } + + /// + /// Initializes a new projection document that also carries the prepared + /// binding plans for the closure, so the projection host can wire a + /// per-generation binding runtime while it materializes the NodeSet. + /// + public WotProjectionDocument( + string closureKey, + ImmutableArray sources, + ArrayOf bindingPlans) + { + ClosureKey = closureKey ?? throw new ArgumentNullException(nameof(closureKey)); + Sources = sources.IsDefault ? [] : sources; + BindingPlans = bindingPlans; + } + + /// + /// Gets the stable closure key this document projects. + /// + public string ClosureKey { get; } + + /// + /// Gets the ordered NodeSet2 sources. + /// + public ImmutableArray Sources { get; } + + /// + /// Gets the prepared binding plans for the closure's members, in the + /// same order the members were projected. Empty when the coordinator + /// did not prepare any bindings (for example a dry run). + /// + public ArrayOf BindingPlans { get; } + } + + /// + /// Marks the host-specific registration carried by a + /// . Implementations are opaque to the + /// materialization pipeline; only the owning + /// interprets them. + /// + public interface IWotProjectionRegistration + { + /// + /// Gets the stable identifier the host assigned to this registration. It + /// is shared by every generation produced from the same closure and is + /// only used for diagnostics. + /// + Guid Id { get; } + } + + /// + /// An opaque handle to a live projection generation held by the host. It + /// wraps the underlying runtime NodeManager registration and records the + /// materialized root NodeIds and node count. + /// + public sealed class WotProjectionHandle + { + /// + /// Initializes a new projection handle. + /// + public WotProjectionHandle( + string closureKey, + long generation, + IWotProjectionRegistration? registration, + ImmutableArray rootNodeIds, + int materializedNodeCount, + string warning = "") + { + ClosureKey = closureKey ?? string.Empty; + Generation = generation; + Registration = registration; + RootNodeIds = rootNodeIds.IsDefault ? [] : rootNodeIds; + MaterializedNodeCount = materializedNodeCount; + Warning = warning ?? string.Empty; + } + + /// + /// Gets the closure key. + /// + public string ClosureKey { get; } + + /// + /// Gets the projection generation. + /// + public long Generation { get; } + + /// + /// Gets the underlying runtime registration (host-specific). + /// + public IWotProjectionRegistration? Registration { get; } + + /// + /// Gets the materialized root NodeIds. + /// + public ImmutableArray RootNodeIds { get; } + + /// + /// Gets the materialized node count. + /// + public int MaterializedNodeCount { get; } + + /// + /// Gets a non-fatal host warning produced after the replacement generation + /// was committed, for example deferred cleanup of the previous generation. + /// + public string Warning { get; } + } + + /// + /// The seam between the materialization coordinator and the live server's + /// NodeManager lifecycle. The production implementation adds a runtime + /// NodeSet on first activation and shadow-reloads it on update, keeping the + /// stable registry NodeManager separate. A test double records the sequence + /// of add/shadow-reload/remove operations without a running server. + /// + public interface IWotProjectionHost + { + /// + /// Adds a projection for its first activation and returns a handle to the + /// new live generation. + /// + ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Shadow-reloads a live projection: new service requests are routed to + /// the replacement generation while the previous generation keeps serving + /// its existing monitored items until they drain. + /// + ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Reloads a live projection and immediately retires the previous + /// generation. Affected data-change monitored items report + /// . + /// + ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Removes a live projection after its monitored items drain, without + /// disconnecting clients. + /// + ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs new file mode 100644 index 0000000000..d0ef538aa0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * 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 Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Resolves the OPC UA target variable an OPC 10101 §6.5.4 target-mapping + /// descriptor declares, against a node manager's freshly imported + /// predefined nodes. Injectable so a host application can supply an + /// alternate resolution strategy (for example one that also consults an + /// external addressing table); is + /// the default, spec-compliant implementation and is always available via + /// direct construction. + /// + public interface IWotTargetVariableResolver + { + /// + /// Resolves the target variable declared by . + /// + /// + /// The fluent builder for the node manager generation whose predefined + /// nodes (including the freshly imported NodeSet2 content) the mapping + /// is resolved against. + /// + /// The target-mapping descriptor to resolve. + /// The resolved target variable. + /// + /// The mapping is missing, malformed, ambiguous, resolves to a node + /// that is not a , or (for a mapping + /// that declares both terms) resolves to a variable whose + /// DataType does not equal the declared target type. + /// + BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping); + } + + /// + /// The default . It implements the + /// OPC 10101 §6.5.4 target-mapping resolution rules by reusing the fluent + /// lookup surface (so lookup failures + /// throw the same deterministic + /// statuses the builder already defines for missing, ambiguous, or + /// wrong-node-class lookups): + /// + /// + /// uav:mapToNodeId alone resolves the exact target and requires it + /// to be a . + /// + /// + /// uav:mapToType alone resolves the unique variable whose + /// DataType equals the target type. + /// + /// + /// Both terms resolve the exact target and additionally validate that its + /// DataType equals the declared target type. + /// + /// + /// + public sealed class WotTargetVariableResolver : IWotTargetVariableResolver + { + /// + public BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (mapping is null) + { + throw new ArgumentNullException(nameof(mapping)); + } + + bool hasTargetNodeId = !string.IsNullOrWhiteSpace(mapping.TargetNodeId); + bool hasTargetType = !string.IsNullOrWhiteSpace(mapping.TargetTypeNodeId); + + if (!hasTargetNodeId && !hasTargetType) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "The target mapping declares neither 'uav:mapToNodeId' nor 'uav:mapToType'."); + } + + if (hasTargetNodeId) + { + NodeId targetNodeId = ParsePortableNodeId(builder, mapping.TargetNodeId!, "uav:mapToNodeId"); + BaseVariableState variable = builder.Variable(targetNodeId).Node; + + if (hasTargetType) + { + NodeId targetTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType"); + if (variable.DataType != targetTypeId) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Target variable '{0}' has DataType '{1}', which does not match the " + + "declared 'uav:mapToType' target type '{2}'.", + targetNodeId, + variable.DataType, + targetTypeId); + } + } + return variable; + } + + NodeId dataTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType"); + return builder.VariableFromDataTypeId(dataTypeId).Node; + } + + /// + /// Parses a portable NodeId (including nsu= forms) against the + /// builder's namespace table, translating every parse failure — + /// including a raised by the + /// parser itself — into a deterministic + /// naming the offending + /// term, so callers never need to special-case the parser's own + /// exception shape. + /// + /// + private static NodeId ParsePortableNodeId(INodeManagerBuilder builder, string text, string term) + { + try + { + return ExpandedNodeId.Parse(text, builder.Context.NamespaceUris); + } + catch (Exception ex) when ( + ex is not OperationCanceledException and not OutOfMemoryException) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "'{0}' value '{1}' is not a valid portable NodeId: {2}", + term, + text, + ex.Message); + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs new file mode 100644 index 0000000000..ce27a71acf --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs @@ -0,0 +1,219 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server; +using Opc.Ua.Server.RuntimeNodeSet; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The production that projects WoT closures + /// onto the live server through the public NodeManager lifecycle. First + /// activation uses + /// ; + /// updates use + /// , + /// so the previous generation keeps serving its existing monitored items + /// until they drain. The stable WoT registry NodeManager is never touched. + /// + public sealed class LifecycleWotProjectionHost : IWotProjectionHost + { + /// + /// Initializes a new host over the supplied lifecycle. + /// + /// The node manager lifecycle to project onto. + /// + /// The optional projection binding runtime factory. When supplied, each + /// runtime NodeSet generation created for a document that carries + /// prepared owns its own + /// binding runtime: it is created after the NodeSet is imported (via + /// ) and disposed with + /// the generation. When null, no binding runtime is wired (the + /// NodeSet is materialized as data only). + /// + public LifecycleWotProjectionHost( + INodeManagerLifecycle lifecycle, + IWotProjectionBindingRuntimeFactory? runtimeFactory = null) + { + m_lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); + m_runtimeFactory = runtimeFactory; + } + + /// + public async ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration registration = await m_lifecycle + .AddRuntimeNodeSetAsync(options, cancellationToken) + .ConfigureAwait(false); + return new WotProjectionHandle( + document.ClosureKey, + registration.Generation, + new NodeManagerProjectionRegistration(registration), + [], + 0); + } + + /// + public async ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerProjectionRegistration wrapper) + { + // No live registration to reload; fall back to a fresh add. + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ShadowReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + new NodeManagerProjectionRegistration(next), + [], + 0, + warning); + } + + /// + public async ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerProjectionRegistration wrapper) + { + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ImmediateReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + new NodeManagerProjectionRegistration(next), + [], + 0, + warning); + } + + /// + public async ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default) + { + if (handle?.Registration is NodeManagerProjectionRegistration wrapper) + { + await m_lifecycle + .RemoveAsync(wrapper.Registration, cancellationToken) + .ConfigureAwait(false); + } + } + + private RuntimeNodeSetOptions BuildOptions(WotProjectionDocument document) + { + var sources = new RuntimeNodeSetSource[document.Sources.Length]; + for (int i = 0; i < document.Sources.Length; i++) + { + WotProjectionSource source = document.Sources[i]; + byte[] xml = source.NodeSetXml; + var uris = new ArrayOf(source.ModelNamespaceUris.ToArray()); + sources[i] = RuntimeNodeSetSource.FromStream( + source.Name, + _ => new ValueTask(new MemoryStream(xml, writable: false)), + uris); + } + var options = new RuntimeNodeSetOptions + { + Sources = new ArrayOf(sources), + AllowLifecycleFromRequestCallback = true + }; + if (m_runtimeFactory is { } runtimeFactory) + { + ArrayOf bindingPlans = document.BindingPlans; + options.ConfigureAsync = (builder, cancellationToken) + => runtimeFactory.CreateAsync(builder, bindingPlans, cancellationToken); + } + return options; + } + + private readonly INodeManagerLifecycle m_lifecycle; + private readonly IWotProjectionBindingRuntimeFactory? m_runtimeFactory; + + /// + /// Carries the lifecycle registration owned by this host through the + /// host-agnostic . + /// + private sealed class NodeManagerProjectionRegistration : IWotProjectionRegistration + { + public NodeManagerProjectionRegistration(NodeManagerRegistration registration) + { + Registration = registration; + } + + public Guid Id => Registration.Id; + + public NodeManagerRegistration Registration { get; } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs new file mode 100644 index 0000000000..2497b06e2f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs @@ -0,0 +1,152 @@ +/* ======================================================================== + * 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; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Lazily opens and caches a single live channel for one compiled form, + /// shared by every reader/writer wired against that form for the lifetime + /// of a projection binding runtime generation. Concurrent first use opens + /// the channel exactly once; a failed open is evicted immediately so a + /// later call can retry. Disposing the slot disposes the channel only if + /// it was opened successfully — a faulted open leaves nothing to dispose + /// and never re-surfaces the original open failure. Once disposed, the + /// slot never opens another channel: racing with or + /// occurring after either observes the channel + /// already claimed for disposal or is rejected outright, so no channel + /// this slot opens can ever escape disposal. + /// + internal sealed class WotBindingChannelSlot + { + /// + /// Initializes a new channel slot for a compiled form. + /// + public WotBindingChannelSlot(WotCompiledForm form, IWotBindingChannelFactory channelFactory) + { + m_form = form ?? throw new ArgumentNullException(nameof(form)); + m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory)); + } + + /// + /// Gets the shared channel, opening it on first use. The channel open + /// itself is not bound to any single caller's cancellation token — it + /// is a generation-scoped resource shared by every reader/writer wired + /// against the same compiled form, so one caller cancelling must not + /// tear down the open for concurrent callers. + /// + /// + /// The slot has already been disposed, or is disposed concurrently + /// before this call is able to reuse or start an open. + /// + public ValueTask GetAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Task task; + lock (m_gate) + { + if (m_disposed) + { + throw new ObjectDisposedException(nameof(WotBindingChannelSlot)); + } + task = m_channelTask ??= OpenAsync(); + } + return WaitAsync(task); + } + + /// + /// Marks the slot disposed so no later call can + /// start a new open, then disposes the cached channel if one was + /// successfully opened (including one still opening concurrently — the + /// disposal awaits it and disposes the result). A faulted or + /// never-started open has no resource to release and is silently + /// ignored so cleanup never re-reports the original open failure. + /// Safe to call more than once; only the first call finds a channel to + /// dispose. + /// + public async ValueTask DisposeAsync() + { + Task? task; + lock (m_gate) + { + task = m_channelTask; + m_channelTask = null; + m_disposed = true; + } + if (task is null) + { + return; + } + IWotBindingChannel channel; + try + { + channel = await task.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return; + } + await channel.DisposeAsync().ConfigureAwait(false); + } + + private Task OpenAsync() + { + return m_channelFactory.OpenChannelAsync(m_form, CancellationToken.None).AsTask(); + } + + private async ValueTask WaitAsync(Task task) + { + try + { + return await task.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + lock (m_gate) + { + if (ReferenceEquals(m_channelTask, task)) + { + m_channelTask = null; + } + } + throw; + } + } + + private readonly WotCompiledForm m_form; + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly Lock m_gate = new(); + private Task? m_channelTask; + private bool m_disposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs new file mode 100644 index 0000000000..1d6e1861eb --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs @@ -0,0 +1,565 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text.Json; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// One resolved (or unresolved) dependency edge between two documents. + /// + public sealed class WotDependency + { + /// + /// Initializes a new dependency edge. + /// + public WotDependency( + string sourceXid, + string targetHref, + string? targetXid, + string refType, + bool resolved) + { + SourceXid = sourceXid; + TargetHref = targetHref; + TargetXid = targetXid; + RefType = refType; + Resolved = resolved; + } + + /// + /// Gets the xid of the dependent document. + /// + public string SourceXid { get; } + + /// + /// Gets the raw href/URI of the dependency. + /// + public string TargetHref { get; } + + /// + /// Gets the xid of the resolved target document, if any. + /// + public string? TargetXid { get; } + + /// + /// Gets the dependency kind (tm:extends / tm:ref / links.rel=type). + /// + public string RefType { get; } + + /// + /// Gets whether the dependency resolved to a stored document. + /// + public bool Resolved { get; } + } + + /// + /// A dependency closure: a set of resources that must be materialized + /// together, with Thing Models topologically ordered before the Thing + /// Descriptions that depend on them. A closure is the default unit of + /// atomicity for a refresh. + /// + public sealed class WotDependencyClosure + { + internal WotDependencyClosure( + string key, + ImmutableArray members, + ImmutableArray orderedResources, + ImmutableArray dependencies, + ImmutableArray diagnostics, + bool hasCycle, + bool hasMissingDependency) + { + Key = key; + Members = members; + OrderedResources = orderedResources; + Dependencies = dependencies; + Diagnostics = diagnostics; + HasCycle = hasCycle; + HasMissingDependency = hasMissingDependency; + } + + /// + /// Gets the stable closure key (sorted member xids). + /// + public string Key { get; } + + /// + /// Gets every member of the closure (populated even on a cycle). + /// + public ImmutableArray Members { get; } + + /// + /// Gets the resources in topological (dependency-first) order. + /// + public ImmutableArray OrderedResources { get; } + + /// + /// Gets the dependency edges within the closure. + /// + public ImmutableArray Dependencies { get; } + + /// + /// Gets the diagnostics for the closure. + /// + public ImmutableArray Diagnostics { get; } + + /// + /// Gets whether the closure contains a dependency cycle. + /// + public bool HasCycle { get; } + + /// + /// Gets whether the closure has an unresolved dependency. + /// + public bool HasMissingDependency { get; } + + /// + /// Gets whether the closure is projectable (no cycle, no missing dependency). + /// + public bool IsProjectable => !HasCycle && !HasMissingDependency; + } + + /// + /// Builds the TD/TM dependency graph from a registry snapshot and partitions + /// it into deterministic dependency closures. References are extracted from + /// links (rel = tm:extends / type / tm:submodel), a top-level + /// tm:extends, and tm:ref pointers, then resolved against the + /// registry by Thing id, xid, or resource id. + /// + public static class WotDependencyGraph + { + /// + /// Resolves a WoT reference href to a stored resource, or null. + /// + public static WotResource? Resolve(WotRegistrySnapshot snapshot, string href) + { + if (snapshot is null || string.IsNullOrWhiteSpace(href)) + { + return null; + } + string trimmed = TrimFragment(href); + // Prefer Thing Models, then any resource, matching by thing id, xid or resource id. + return MatchIn(snapshot.ResourcesOfKind(WoTDocumentKindEnum.ThingModel), trimmed) + ?? MatchIn(snapshot.AllResources(), trimmed); + } + + /// + /// Extracts the outgoing dependency references of a single document. + /// + public static IReadOnlyList<(string Href, string RefType)> ExtractReferences( + ReadOnlyMemory document, + int maxJsonDepth) + { + var references = new List<(string, string)>(); + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth }; + using var json = JsonDocument.Parse(document, options); + JsonElement root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return references; + } + CollectLinks(root, references); + CollectExtends(root, references); + CollectTmRefs(root, references, 0, maxJsonDepth); + } + catch (JsonException) + { + // A document that cannot be parsed contributes no edges; its own + // projection reports the parse failure. + } + return references; + } + + /// + /// Builds the dependency closures for the selected resources. Selected + /// resources are grouped into weakly-connected components (so a shared + /// Thing Model lands in a single closure), then each component is + /// topologically ordered. + /// + public static ImmutableArray BuildClosures( + WotRegistrySnapshot snapshot, + IReadOnlyCollection selected, + int maxJsonDepth) + { + if (selected.Count == 0) + { + return []; + } + + // Expand the selection to include resolvable transitive dependencies. + var byXid = new Dictionary(StringComparer.Ordinal); + var queue = new Queue(); + foreach (WotResource resource in selected) + { + if (!byXid.ContainsKey(resource.Xid)) + { + byXid[resource.Xid] = resource; + queue.Enqueue(resource); + } + } + + var edges = new Dictionary>(StringComparer.Ordinal); + while (queue.Count > 0) + { + WotResource resource = queue.Dequeue(); + var list = new List(); + edges[resource.Xid] = list; + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + continue; + } + foreach ((string href, string refType) in ExtractReferences( + version.Content, maxJsonDepth)) + { + WotResource? target = Resolve(snapshot, href); + list.Add(new WotDependency( + resource.Xid, href, target?.Xid, refType, target is not null)); + if (target is not null && !byXid.ContainsKey(target.Xid)) + { + byXid[target.Xid] = target; + queue.Enqueue(target); + } + } + } + + // Weakly-connected components via union-find over resolved edges. + var parent = new Dictionary(StringComparer.Ordinal); + foreach (string xid in byXid.Keys) + { + parent[xid] = xid; + } + foreach (List list in edges.Values) + { + foreach (WotDependency edge in list) + { + if (edge.Resolved && + edge.TargetXid is not null && + byXid.ContainsKey(edge.TargetXid)) + { + Union(parent, edge.SourceXid, edge.TargetXid); + } + } + } + + var components = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair entry in byXid) + { + string root = Find(parent, entry.Key); + if (!components.TryGetValue(root, out List? members)) + { + members = []; + components[root] = members; + } + members.Add(entry.Value); + } + + ImmutableArray.Builder closures = + ImmutableArray.CreateBuilder(); + foreach (List members in components.Values) + { + closures.Add(BuildClosure(members, edges, byXid)); + } + // Deterministic order by closure key. + return [.. closures.OrderBy(c => c.Key, StringComparer.Ordinal)]; + } + + private static WotDependencyClosure BuildClosure( + List members, + Dictionary> edges, + Dictionary byXid) + { + var memberXids = new HashSet(members.Select(m => m.Xid), StringComparer.Ordinal); + ImmutableArray.Builder dependencies = ImmutableArray.CreateBuilder(); + ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder(); + bool missing = false; + + // Adjacency (source depends on target): target must be ordered first. + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (WotResource member in members) + { + adjacency[member.Xid] = []; + } + foreach (WotResource member in members) + { + if (!edges.TryGetValue(member.Xid, out List? list)) + { + continue; + } + foreach (WotDependency edge in list) + { + dependencies.Add(edge); + if (!edge.Resolved) + { + missing = true; + diagnostics.Add( + $"Unresolved {edge.RefType} dependency '{edge.TargetHref}' " + + $"referenced by '{edge.SourceXid}'."); + } + else if (edge.TargetXid is not null && memberXids.Contains(edge.TargetXid)) + { + adjacency[member.Xid].Add(edge.TargetXid); + } + } + } + + (ImmutableArray ordered, bool hasCycle) = TopologicalSort( + members, adjacency, byXid); + if (hasCycle) + { + diagnostics.Add( + "Dependency cycle detected among: " + + string.Join(", ", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal))); + } + + string key = string.Join( + "|", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal)); + var memberArray = members + .OrderBy(m => m.Xid, StringComparer.Ordinal) + .ToImmutableArray(); + return new WotDependencyClosure( + key, + memberArray, + ordered, + dependencies.ToImmutable(), + diagnostics.ToImmutable(), + hasCycle, + missing); + } + + private static (ImmutableArray Ordered, bool HasCycle) TopologicalSort( + List members, + Dictionary> adjacency, + Dictionary byXid) + { + // 0 = unvisited, 1 = in-progress, 2 = done. + var color = new Dictionary(StringComparer.Ordinal); + var ordered = new List(); + bool hasCycle = false; + + // Deterministic iteration order. + IEnumerable roots = members + .Select(m => m.Xid) + .OrderBy(x => x, StringComparer.Ordinal); + + void Visit(string xid) + { + if (hasCycle) + { + return; + } + color.TryGetValue(xid, out int state); + if (state == 2) + { + return; + } + if (state == 1) + { + hasCycle = true; + return; + } + color[xid] = 1; + foreach (string dependency in adjacency[xid] + .OrderBy(x => x, StringComparer.Ordinal)) + { + Visit(dependency); + if (hasCycle) + { + return; + } + } + color[xid] = 2; + ordered.Add(byXid[xid]); + } + + foreach (string root in roots) + { + Visit(root); + } + + return hasCycle + ? (ImmutableArray.Empty, true) + : ([.. ordered], false); + } + + private static WotResource? MatchIn(IEnumerable resources, string href) + { + foreach (WotResource resource in resources) + { + if (string.Equals(resource.ThingId, href, StringComparison.Ordinal) || + string.Equals(resource.Xid, href, StringComparison.Ordinal) || + string.Equals(RegistryUri(resource), href, StringComparison.Ordinal) || + string.Equals(resource.ResourceId, href, StringComparison.Ordinal) || + href.EndsWith("/" + resource.ResourceId, StringComparison.Ordinal)) + { + return resource; + } + } + return null; + } + + private static string RegistryUri(WotResource resource) + { + return $"urn:wot:{resource.GroupId}/{resource.ResourceId}"; + } + + private static string TrimFragment(string href) + { + int hash = href.AsSpan().IndexOf('#'); + return hash >= 0 ? href[..hash] : href; + } + + private static void CollectLinks( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("links", out JsonElement links) || + links.ValueKind != JsonValueKind.Array) + { + return; + } + foreach (JsonElement link in links.EnumerateArray()) + { + if (link.ValueKind != JsonValueKind.Object || + !link.TryGetProperty("href", out JsonElement hrefElement) || + hrefElement.ValueKind != JsonValueKind.String) + { + continue; + } + string rel = link.TryGetProperty("rel", out JsonElement relElement) && + relElement.ValueKind == JsonValueKind.String + ? relElement.GetString() ?? string.Empty + : string.Empty; + if (rel is "tm:extends" or "type" or "tm:submodel" or "collection" or "item") + { + references.Add((hrefElement.GetString() ?? string.Empty, rel)); + } + } + } + + private static void CollectExtends( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("tm:extends", out JsonElement extends)) + { + return; + } + switch (extends.ValueKind) + { + case JsonValueKind.String: + references.Add((extends.GetString() ?? string.Empty, "tm:extends")); + break; + case JsonValueKind.Array: + foreach (JsonElement item in extends.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + references.Add((item.GetString() ?? string.Empty, "tm:extends")); + } + else if (item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("href", out JsonElement href) && + href.ValueKind == JsonValueKind.String) + { + references.Add((href.GetString() ?? string.Empty, "tm:extends")); + } + } + break; + } + } + + private static void CollectTmRefs( + JsonElement element, + List<(string, string)> references, + int depth, + int maxDepth) + { + if (depth > maxDepth) + { + return; + } + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + if (string.Equals(property.Name, "tm:ref", StringComparison.Ordinal) && + property.Value.ValueKind == JsonValueKind.String) + { + references.Add((property.Value.GetString() ?? string.Empty, "tm:ref")); + } + else + { + CollectTmRefs(property.Value, references, depth + 1, maxDepth); + } + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + CollectTmRefs(item, references, depth + 1, maxDepth); + } + break; + } + } + + private static string Find(Dictionary parent, string node) + { + string root = node; + while (!string.Equals(parent[root], root, StringComparison.Ordinal)) + { + root = parent[root]; + } + // Path compression. + while (!string.Equals(parent[node], root, StringComparison.Ordinal)) + { + string next = parent[node]; + parent[node] = root; + node = next; + } + return root; + } + + private static void Union(Dictionary parent, string a, string b) + { + string rootA = Find(parent, a); + string rootB = Find(parent, b); + if (!string.Equals(rootA, rootB, StringComparison.Ordinal)) + { + parent[rootB] = rootA; + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs new file mode 100644 index 0000000000..03c63cbe47 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs @@ -0,0 +1,1239 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Coordinates projecting registry documents into the AddressSpace. It parses + /// and validates each document with , builds the TD/TM + /// dependency closures, converts each closure to one or more NodeSet2 + /// documents and projects them through the + /// (runtime NodeSet Add for first activation, ShadowReload for updates). The + /// stable registry NodeManager is kept separate. Independent closures commit + /// independently; a failed or invalid closure retains its previous active + /// generation. An unchanged closure (same digest, options and binder version) + /// returns and emits no model change. + /// + public sealed class WotMaterializationCoordinator : IDisposable + { + /// + /// Initializes a new coordinator. + /// + public WotMaterializationCoordinator( + IWotRegistryService registry, + IWotProjectionHost projectionHost, + IWotBinderRegistry? binderRegistry = null, + WotNodeSetConverterOptions? converterOptions = null, + IWotDocumentConverter? documentConverter = null, + IEnumerable? nodeSetContributors = null, + IWotNodeSetResolver? nodeSetResolver = null) + { + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_host = projectionHost ?? throw new ArgumentNullException(nameof(projectionHost)); + m_binders = binderRegistry ?? NullWotBinderRegistry.Instance; + m_converterOptions = converterOptions ?? new WotNodeSetConverterOptions(); + m_converter = documentConverter + ?? new WotNodeSetDocumentConverter(m_converterOptions); + m_nodeSetContributors = nodeSetContributors is null + ? [] + : [.. nodeSetContributors]; + m_nodeSetResolver = nodeSetResolver; + } + + /// + /// Raised for each materialization event (resource / validation / load / refresh). + /// + public event EventHandler? Event; + + /// + /// Gets the current refresh generation. + /// + public uint Generation => m_generation; + + /// + /// Refreshes (re-projects) the registry into the AddressSpace and returns + /// the detailed result. + /// + /// + public async ValueTask RefreshAsync( + WotRefreshRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + DateTime start = DateTime.UtcNow; + WotRegistrySnapshot snapshot = m_registry.Current; + + if (request.ExpectedGeneration != 0 && + request.ExpectedGeneration != (uint)snapshot.Generation) + { + return RejectedResult(request, snapshot, start); + } + + bool dryRun = request.Options?.DryRun ?? false; + bool force = request.Options?.Force ?? false; + bool strict = StrictBindings; + HashSet selectedXids = ResolveSelection(snapshot, request.Selection); + + var enabled = snapshot.AllResources() + .Where(r => r.Enabled && r.DefaultVersion is not null) + .ToList(); + ImmutableArray closures = + WotDependencyGraph.BuildClosures( + snapshot, enabled, m_converterOptions.MaxJsonDepth); + + var targetKeys = new HashSet( + closures.Select(c => c.Key), StringComparer.Ordinal); + + uint newGeneration = ++m_generation; + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + var projections = new List(); + int succeeded = 0; + int unchanged = 0; + int failed = 0; + int skipped = 0; + int retired = 0; + + // Retire tracked closures no longer desired (deleted / disabled / + // membership changed) after their monitored items drain. + retired += await ReconcileRetirementsAsync( + targetKeys, cancellationToken).ConfigureAwait(false); + + foreach (WotDependencyClosure closure in closures) + { + cancellationToken.ThrowIfCancellationRequested(); + bool inScope = selectedXids.Count == 0 || + closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) || + MembersOf(closure).Any(r => selectedXids.Contains(r.Xid)); + + ClosureOutcome outcome = await ProcessClosureAsync( + snapshot, closure, newGeneration, force && inScope, + dryRun, strict, cancellationToken).ConfigureAwait(false); + + foreach (WoTResourceLoadResultDataType result in outcome.Results) + { + string resultXid = result.Xid ?? string.Empty; + if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid)) + { + continue; + } + results.Add(result); + switch (result.Outcome) + { + case WoTOutcomeEnum.Success: + case WoTOutcomeEnum.Warning: + succeeded++; + break; + case WoTOutcomeEnum.Unchanged: + unchanged++; + break; + case WoTOutcomeEnum.Skipped: + skipped++; + break; + default: + failed++; + break; + } + } + projections.AddRange(outcome.Projections); + } + + if (!dryRun && projections.Count > 0) + { + await m_registry.ApplyProjectionResultsAsync( + projections, cancellationToken).ConfigureAwait(false); + } + + WoTOutcomeEnum overall = failed > 0 + ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) + : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged); + + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = dryRun ? 0 : newGeneration, + Outcome = overall, + Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure, + StartTime = start, + EndTime = DateTime.UtcNow, + Total = (uint)results.Count, + Succeeded = (uint)succeeded, + Unchanged = (uint)unchanged, + Failed = (uint)failed, + Skipped = (uint)skipped, + Retired = (uint)retired + }; + + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.RefreshCompleted) + { + Generation = newGeneration, + RequestId = request.RequestId ?? string.Empty, + Outcome = overall, + Summary = summary + }); + + return new WotRefreshResult( + summary, results.ToImmutable(), dryRun ? 0u : newGeneration); + } + finally + { + m_mutex.Release(); + } + } + + /// + /// Removes all live projections (used during NodeManager shutdown). + /// + public async ValueTask RemoveAllAsync(CancellationToken cancellationToken = default) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + foreach (ClosureState state in m_closures.Values) + { + // Deactivate bindings before removing the projection (before + // retirement / unload), then release the projection handle. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + if (state.Handle is not null) + { + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + } + } + m_closures.Clear(); + } + finally + { + m_mutex.Release(); + } + } + + /// + /// Gets or sets whether unsupported forms fail a strict closure. + /// + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how previous projection generations are retired after a + /// successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets the binding capability snapshots advertised by the registered + /// binders. These populate the registry SelectedBindings node and + /// contribute to refresh unchanged-detection. + /// + public IReadOnlyList BindingCapabilities => m_binders.Capabilities; + + /// + /// Gets or sets the live server namespace table used to resolve a + /// projection's recorded root into a + /// concrete server NodeId after its owning namespace is registered by + /// the projection host. When null, materialized root NodeIds are + /// not reported. + /// + public NamespaceTable? ServerNamespaceUris { get; set; } + + /// + /// Releases the mutex used to serialise refreshes. + /// + public void Dispose() + { + m_mutex.Dispose(); + } + + private static ByteString DigestOf(WotResource resource) + { + return (ByteString)(resource.DefaultVersion?.Digest ?? []); + } + + private async ValueTask ProcessClosureAsync( + WotRegistrySnapshot snapshot, + WotDependencyClosure closure, + uint generation, + bool force, + bool dryRun, + bool strict, + CancellationToken cancellationToken) + { + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + var projections = new List(); + IReadOnlyList members = MembersOf(closure); + + // Unprojectable closure: cycle or missing dependency. Retain the + // previous active generation and mark members failed. + if (!closure.IsProjectable) + { + WoTPhaseEnum phase = closure.HasMissingDependency + ? WoTPhaseEnum.DependencyResolution + : WoTPhaseEnum.DependencyResolution; + string reason = string.Join("; ", closure.Diagnostics); + foreach (WotResource member in members) + { + results.Add(FailResult(member, generation, phase, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Project in topological (dependency-first) order. + members = closure.OrderedResources; + + byte[] aggregateDigest = ComputeAggregateDigest(members); + m_closures.TryGetValue(closure.Key, out ClosureState? tracked); + + // Unchanged: same digest/options/binder version, and not forced. + if (tracked?.Handle is not null && + !force && + WotContentDigest.Equal(tracked.AggregateDigest, aggregateDigest)) + { + foreach (WotResource member in members) + { + results.Add(UnchangedResult(member, tracked.Generation)); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Convert every member to a NodeSet2 source in dependency order. + ImmutableArray.Builder sources = ImmutableArray.CreateBuilder(); + var perMemberNodeCount = new Dictionary(StringComparer.Ordinal); + var perMemberRoot = new Dictionary(StringComparer.Ordinal); + var bindingPlans = new List(); + bool degraded = false; + var requiredNamespaces = new HashSet(StringComparer.Ordinal); + var ownedNamespaces = new HashSet(StringComparer.Ordinal); + + foreach (WotResource member in members) + { + WotResourceVersion? version = member.DefaultVersion; + if (version is null) + { + const string reason = "Resource has no default version."; + results.Add(FailResult(member, generation, WoTPhaseEnum.Fetch, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + (UANodeSet? nodeSet, ExpandedNodeId root, string? conversionError) = + await TryConvertAsync(member, snapshot, cancellationToken) + .ConfigureAwait(false); + if (nodeSet is not null && m_nodeSetContributors.Length > 0) + { + // Contributors run after conversion and before any variable is created, which + // is when a programmatically discovered DataType (a controller UDT, say) has to + // exist for a uav:mapByFieldPath mapping to resolve against it. + foreach (IWotNodeSetContributor contributor in m_nodeSetContributors) + { + await contributor + .ContributeAsync(member, nodeSet, cancellationToken) + .ConfigureAwait(false); + } + } + if (nodeSet is null) + { + WoTValidationOutcomeDataType validation = FormatFailure(conversionError); + results.Add(FailResult( + member, generation, WoTPhaseEnum.FormatValidation, conversionError)); + projections.Add(FailProjection(member, conversionError, validation)); + RaiseValidationFailure(member, generation, validation, conversionError); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + WotBindingPlan plan = m_binders.Prepare(BuildPlanRequest(member, version)); + bindingPlans.Add(plan); + if (!plan.FullySupported) + { + if (strict) + { + const string reason = "Unsupported binding forms in a strict closure."; + results.Add(FailResult( + member, generation, WoTPhaseEnum.Projection, reason)); + projections.Add(FailProjection(member, reason)); + RaiseBindingFailure(member, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + degraded = true; + RaiseBindingFailure(member, + "Unsupported binding forms materialized as degraded nodes."); + } + else if (plan.HasNonExecutableForms) + { + // A validated plan whose binding has no runtime executor (for + // example a planner-only protocol): materialize the nodes but + // flag the closure as degraded so callers know they cannot be + // driven yet. + degraded = true; + } + + byte[] xml = SerializeNodeSet(nodeSet); + perMemberNodeCount[member.Xid] = nodeSet.Items?.Length ?? 0; + if (!root.IsNull) + { + perMemberRoot[member.Xid] = root; + } + sources.Add(new WotProjectionSource( + member.ResourceId, OwnedModelUris(nodeSet), xml)); + CollectRequiredNamespaces(nodeSet, requiredNamespaces, ownedNamespaces); + } + + // Resolve any companion-specification namespace the closure depends on that neither the + // closure itself nor the server already provides. Resolved models are prepended to the + // sources so they materialize before the documents that reference them. A namespace + // that stays unresolved is reported, never silently dropped: the projection then fails + // with a message naming exactly what is missing. + (ImmutableArray resolved, ImmutableArray unresolved) = + await ResolveDependencyModelsAsync( + requiredNamespaces, ownedNamespaces, cancellationToken) + .ConfigureAwait(false); + if (!resolved.IsDefaultOrEmpty) + { + sources.InsertRange(0, resolved); + } + if (!unresolved.IsDefaultOrEmpty) + { + degraded = true; + foreach (WotResource member in members) + { + RaiseBindingFailure( + member, + "Unresolved dependency namespace(s): " + string.Join(", ", unresolved)); + } + } + + if (dryRun) + { + foreach (WotResource member in members) + { + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success, + Phase = WoTPhaseEnum.Projection, + LoadState = member.LoadState, + Generation = 0, + MaterializedNodeCount = (uint)(perMemberNodeCount.TryGetValue( + member.Xid, out int c) ? c : 0), + ContentDigest = DigestOf(member), + Message = "Dry run; no projection committed." + }); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + var document = new WotProjectionDocument( + closure.Key, sources.ToImmutable(), bindingPlans.ToArrayOf()); + WotProjectionHandle handle; + try + { + if (tracked?.Handle is null) + { + handle = await m_host.AddAsync(document, cancellationToken) + .ConfigureAwait(false); + } + else if (RetirementPolicy == WotProjectionRetirementPolicy.Immediate) + { + handle = await m_host.ImmediateReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + else + { + handle = await m_host.ShadowReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Projection failed: retain the previous active generation and its + // tracked binding plans. The shadow switch never happened, so the + // old plans remain active and no deactivation is performed + // (rollback: old plans survive when the new switch fails). + foreach (WotResource member in members) + { + results.Add(FailResult( + member, generation, WoTPhaseEnum.Activation, ex.Message)); + projections.Add(FailProjection(member, ex.Message)); + RaiseLoadFailure(member, generation, ex.Message); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + string projectionWarning = handle.Warning; + if (projectionWarning.Length != 0) + { + degraded = true; + } + + // The shadow switch (or first add) succeeded. On an update, retire the + // previously tracked binding plans before publishing the new closure + // state so they are not leaked. This runs after the successful switch + // and before the closure state is replaced; deactivating the old plans + // first (then activating the new plans below) keeps a resource that is + // shared between the old and new plan sets continuously bound. + if (tracked is not null) + { + foreach (WotBindingPlan plan in tracked.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + } + + m_closures[closure.Key] = new ClosureState + { + Key = closure.Key, + Handle = handle, + AggregateDigest = aggregateDigest, + Generation = generation, + MemberXids = [.. members.Select(m => m.Xid)], + ModelNamespaceUris = [.. sources.SelectMany(s => s.ModelNamespaceUris)], + BindingPlans = [.. bindingPlans] + }; + foreach (string namespaceUri in sources.SelectMany(s => s.ModelNamespaceUris)) + { + m_projectionNamespaceUris.Add(namespaceUri); + } + + foreach (WotBindingPlan plan in bindingPlans) + { + await m_binders.ActivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + + WoTOutcomeEnum memberOutcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success; + foreach (WotResource member in members) + { + int nodeCount = perMemberNodeCount.TryGetValue(member.Xid, out int c) ? c : 0; + NodeId rootNodeId = perMemberRoot.TryGetValue(member.Xid, out ExpandedNodeId root) + ? ResolveRootNodeId(root) + : NodeId.Null; + WoTValidationOutcomeDataType validation = SuccessValidation(); + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = memberOutcome, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)nodeCount, + RootNodeId = rootNodeId, + ContentDigest = DigestOf(member), + Message = projectionWarning.Length != 0 + ? "Projected with warning: " + projectionWarning + : degraded ? "Projected with degraded bindings." : "Projected." + }); + projections.Add(new WotResourceProjection( + member.GroupId, + member.ResourceId, + WoTLoadStateEnum.Active, + member.DefaultVersionId, + generation, + nodeCount, + rootNodeId, + validation, + projectionWarning.Length == 0 + ? [] + : [projectionWarning], + DateTime.UtcNow)); + RaiseResource(member, generation, memberOutcome, WoTLoadStateEnum.Active); + } + + return new ClosureOutcome(results.ToImmutable(), projections); + } + + private async ValueTask ReconcileRetirementsAsync( + HashSet targetKeys, + CancellationToken cancellationToken) + { + int retired = 0; + foreach (string key in (List)[.. m_closures.Keys.Where(k => !targetKeys.Contains(k))]) + { + if (m_closures.TryGetValue(key, out ClosureState? state)) + { + if (state.Handle is not null) + { + // Deactivate bindings before retiring the projection. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + retired++; + } + m_closures.Remove(key); + } + } + return retired; + } + + private async ValueTask<(UANodeSet? NodeSet, ExpandedNodeId Root, string? Error)> TryConvertAsync( + WotResource resource, WotRegistrySnapshot snapshot, CancellationToken cancellationToken) + { + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + return (null, default, "Resource has no default version."); + } + WotConversionOutput output = await m_converter + .ConvertAsync(resource, version.Content, snapshot, cancellationToken) + .ConfigureAwait(false); + if (!output.Succeeded) + { + return (null, default, output.Errors.IsDefaultOrEmpty + ? "The document could not be converted to a NodeSet." + : string.Join("; ", output.Errors)); + } + return (output.NodeSet, output.RootNodeId, null); + } + + /// + /// Resolves a projection root, recorded before lifecycle add as an + /// absolute , into a concrete server NodeId + /// once its owning namespace has been registered by the projection host. + /// Returns NodeId.Null when there is no root or the namespace table is + /// unavailable or does not yet contain the owning namespace. + /// + private NodeId ResolveRootNodeId(ExpandedNodeId root) + { + if (root.IsNull) + { + return NodeId.Null; + } + NamespaceTable? namespaces = ServerNamespaceUris; + if (namespaces is null) + { + return NodeId.Null; + } + var resolved = ExpandedNodeId.ToNodeId(root, namespaces); + return resolved.IsNull ? NodeId.Null : resolved; + } + + private WotBindingPlanRequest BuildPlanRequest( + WotResource resource, WotResourceVersion version) + { + return WotBindingPlanRequest.FromDocument( + resource.Xid, resource.Kind, version.Content, m_converterOptions.MaxJsonDepth); + } + + private byte[] ComputeAggregateDigest(IReadOnlyList members) + { + using var sha = SHA256.Create(); + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true)) + { + foreach (WotResource member in members + .OrderBy(m => m.Xid, StringComparer.Ordinal)) + { + writer.Write(member.Xid); + writer.Write(member.DefaultVersionId ?? string.Empty); + byte[] digest = member.DefaultVersion?.Digest ?? []; + writer.Write(digest.Length); + writer.Write(digest); + } + writer.Write(m_converterOptions.MaxJsonDepth); + writer.Write(BinderVersion); + } + buffer.Position = 0; + // TODO: SHA256.HashData(ReadOnlySpan) is only available on .NET 5+; + // this project also targets net472/net48/netstandard2.1, where the instance + // ComputeHash API is the portable equivalent. Revisit if the minimum TFM + // floor is ever raised to drop those targets. +#pragma warning disable CA1850 + return sha.ComputeHash(buffer.ToArray()); +#pragma warning restore CA1850 + } + + private string BinderVersion + { + get + { + IReadOnlyList caps = m_binders.Capabilities; + if (caps.Count == 0) + { + return "none"; + } + var builder = new StringBuilder(); + foreach (WoTBindingCapabilityDataType cap in caps) + { + builder.Append(cap.BindingUri).Append(';').Append(cap.ProfileVersion).Append('|'); + } + return builder.ToString(); + } + } + + private static byte[] SerializeNodeSet(UANodeSet nodeSet) + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + return stream.ToArray(); + } + + private static ImmutableArray OwnedModelUris(UANodeSet nodeSet) + { + if (nodeSet.Models is { Length: > 0 }) + { + var uris = new List(nodeSet.Models.Length); + foreach (ModelTableEntry model in nodeSet.Models) + { + if (!string.IsNullOrEmpty(model.ModelUri)) + { + uris.Add(model.ModelUri); + } + } + if (uris.Count > 0) + { + return [.. uris]; + } + } + if (nodeSet.NamespaceUris is { Length: > 0 }) + { + return + [ + .. nodeSet.NamespaceUris + .Where(u => !string.Equals(u, Ua.Namespaces.OpcUa, StringComparison.Ordinal)) + ]; + } + return []; + } + + /// + /// Records the namespaces a converted NodeSet owns and the ones it declares a dependency + /// on, so the closure's unmet dependencies can be resolved once for the whole projection. + /// + private static void CollectRequiredNamespaces( + UANodeSet nodeSet, + HashSet required, + HashSet owned) + { + foreach (string uri in OwnedModelUris(nodeSet)) + { + owned.Add(uri); + } + if (nodeSet.Models is null) + { + return; + } + foreach (ModelTableEntry model in nodeSet.Models) + { + if (model?.RequiredModel is null) + { + continue; + } + foreach (ModelTableEntry dependency in model.RequiredModel) + { + if (!string.IsNullOrEmpty(dependency?.ModelUri) && + !string.Equals( + dependency!.ModelUri, Ua.Namespaces.OpcUa, StringComparison.Ordinal)) + { + required.Add(dependency.ModelUri); + } + } + } + } + + /// + /// Asks the configured for every dependency namespace the + /// closure needs but neither owns nor finds on the server, recursing into whatever it gets + /// back. Returns the resolved models in dependency order together with the namespaces that + /// stayed unresolved. + /// + private async ValueTask<(ImmutableArray Resolved, + ImmutableArray Unresolved)> ResolveDependencyModelsAsync( + HashSet required, + HashSet owned, + CancellationToken cancellationToken) + { + var pending = new Queue(); + foreach (string uri in required) + { + if (!owned.Contains(uri) && !IsKnownToServer(uri)) + { + pending.Enqueue(uri); + } + } + if (pending.Count == 0) + { + return ([], []); + } + if (m_nodeSetResolver is null) + { + return ([], [.. pending]); + } + + ImmutableArray.Builder resolved = + ImmutableArray.CreateBuilder(); + ImmutableArray.Builder unresolved = ImmutableArray.CreateBuilder(); + var seen = new HashSet(pending, StringComparer.Ordinal); + while (pending.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + string uri = pending.Dequeue(); + Stream? stream = await m_nodeSetResolver + .TryResolveAsync(uri, cancellationToken) + .ConfigureAwait(false); + if (stream is null) + { + unresolved.Add(uri); + continue; + } + + byte[] xml; + UANodeSet? dependency; + using (stream) + { + MemoryStream? buffer = await CopyResolverDocumentAsync( + stream, uri, unresolved, cancellationToken) + .ConfigureAwait(false); + if (buffer is null) + { + continue; + } + using (buffer) + { + xml = buffer.ToArray(); + buffer.Position = 0; + dependency = UANodeSet.Read(buffer); + } + } + if (dependency is null) + { + // A resolver that hands back something unreadable is treated exactly like one + // that declined, so the namespace is reported rather than faulting onboarding. + unresolved.Add(uri); + continue; + } + + foreach (string ownedUri in OwnedModelUris(dependency)) + { + owned.Add(ownedUri); + } + // A resolved model may itself depend on further namespaces. + var nested = new HashSet(StringComparer.Ordinal); + CollectRequiredNamespaces(dependency, nested, owned); + foreach (string nestedUri in nested) + { + if (!owned.Contains(nestedUri) && + !IsKnownToServer(nestedUri) && + seen.Add(nestedUri)) + { + pending.Enqueue(nestedUri); + } + } + resolved.Add(new WotProjectionSource(uri, OwnedModelUris(dependency), xml)); + } + + // Dependencies are appended in resolution order, so reverse to put the deepest model + // first: a model must be materialized before the model that requires it. + resolved.Reverse(); + return (resolved.ToImmutable(), unresolved.ToImmutable()); + } + + private bool IsKnownToServer(string namespaceUri) + { + foreach (ClosureState closure in m_closures.Values) + { + if (closure.ModelNamespaceUris.Contains(namespaceUri, StringComparer.Ordinal)) + { + return true; + } + } + if (m_projectionNamespaceUris.Contains(namespaceUri)) + { + return false; + } + NamespaceTable? namespaces = ServerNamespaceUris; + return namespaces is not null && namespaces.GetIndex(namespaceUri) >= 0; + } + + private async ValueTask CopyResolverDocumentAsync( + Stream stream, + string namespaceUri, + ImmutableArray.Builder unresolved, + CancellationToken cancellationToken) + { + int maxBytes = m_converterOptions.MaxResolverDocumentBytes; + var buffer = new MemoryStream(); + var chunk = new byte[81920]; + bool keepBuffer = false; + try + { + while (true) + { + int read = await ReadBlockAsync(stream, chunk, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + buffer.Position = 0; + keepBuffer = true; + return buffer; + } + if (buffer.Length + read > maxBytes) + { + unresolved.Add( + namespaceUri + + " (resolver response exceeded " + + maxBytes.ToString(CultureInfo.InvariantCulture) + + " bytes)"); + return null; + } + buffer.Write(chunk, 0, read); + } + } + finally + { + if (!keepBuffer) + { + buffer.Dispose(); + } + } + } + + private static async ValueTask ReadBlockAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { +#if NETFRAMEWORK || NETSTANDARD2_0 + return await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false); +#else + return await stream.ReadAsync(buffer.AsMemory(), cancellationToken) + .ConfigureAwait(false); +#endif + } + + private static IReadOnlyList MembersOf(WotDependencyClosure closure) + { + return closure.Members.IsDefaultOrEmpty + ? Array.Empty() + : closure.Members; + } + + private HashSet ResolveSelection( + WotRegistrySnapshot snapshot, + ImmutableArray selectors) + { + var set = new HashSet(StringComparer.Ordinal); + if (selectors.IsDefaultOrEmpty) + { + return set; + } + foreach (WoTResourceSelectorDataType selector in selectors) + { + foreach (WotResource resource in snapshot.AllResources()) + { + if (Matches(resource, selector)) + { + set.Add(resource.Xid); + } + } + } + return set; + } + + private static bool Matches(WotResource resource, WoTResourceSelectorDataType selector) + { + if (!string.IsNullOrEmpty(selector.Xid) && + !string.Equals(selector.Xid, resource.Xid, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.GroupId) && + !string.Equals(selector.GroupId, resource.GroupId, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.ResourceId) && + !string.Equals(selector.ResourceId, resource.ResourceId, StringComparison.Ordinal)) + { + return false; + } + return true; + } + + private static WoTResourceLoadResultDataType FailResult( + WotResource resource, uint generation, WoTPhaseEnum phase, string? message) + { + return new() + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + Phase = phase, + LoadState = WoTLoadStateEnum.Failed, + Generation = generation, + MaterializedNodeCount = 0, + ContentDigest = DigestOf(resource), + Message = message ?? string.Empty + }; + } + + private static WoTResourceLoadResultDataType UnchangedResult( + WotResource resource, uint generation) + { + return new() + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.ActiveVersionId ?? resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Unchanged, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)resource.MaterializedNodeCount, + ContentDigest = DigestOf(resource), + Message = "Content digest unchanged." + }; + } + + private static WotResourceProjection FailProjection( + WotResource resource, string? message, WoTValidationOutcomeDataType? validation = null) + { + return new( + resource.GroupId, + resource.ResourceId, + WoTLoadStateEnum.Failed, + activeVersionId: null, + resource.RefreshGeneration, + resource.MaterializedNodeCount, + rootNodeId: NodeId.Null, + validation, + string.IsNullOrEmpty(message) + ? [] + : [message!], + DateTime.UtcNow) + { + // Keep the previous active projection when a refresh fails. + RetainPreviousActiveVersion = true + }; + } + + private static WoTValidationOutcomeDataType SuccessValidation() + { + return new() + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Success, + CompatibilityValidated = true, + CompatibilityOutcome = WoTOutcomeEnum.Success, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + + private static WoTValidationOutcomeDataType FormatFailure(string? reason) + { + return new() + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Failed, + FormatReason = reason ?? string.Empty, + CompatibilityValidated = false, + CompatibilityOutcome = WoTOutcomeEnum.Skipped, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + + private void RaiseResource( + WotResource resource, uint generation, WoTOutcomeEnum outcome, WoTLoadStateEnum state) + { + RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.Resource) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Activation, + Outcome = outcome, + LoadState = state + }); + } + + private void RaiseLoadFailure(WotResource resource, uint generation, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Projection, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + } + + private void RaiseValidationFailure( + WotResource resource, uint generation, + WoTValidationOutcomeDataType validation, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.ValidationFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.FormatValidation, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Validation = validation, + Reason = reason ?? string.Empty + }); + } + + private void RaiseBindingFailure(WotResource resource, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.BindingFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + DocumentKind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + } + + private void RaiseEvent(WotMaterializationEventArgs args) + { + Event?.Invoke(this, args); + } + + private WotRefreshResult RejectedResult( + WotRefreshRequest request, WotRegistrySnapshot snapshot, DateTime start) + { + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = 0, + Outcome = WoTOutcomeEnum.Rejected, + StartTime = start, + EndTime = DateTime.UtcNow + }; + return new WotRefreshResult( + summary, [], + (uint)snapshot.Generation); + } + + private sealed class ClosureState + { + public string Key { get; set; } = string.Empty; + public WotProjectionHandle? Handle { get; set; } + public byte[] AggregateDigest { get; set; } = []; + public uint Generation { get; set; } + public ImmutableArray MemberXids { get; set; } = []; + + public ImmutableArray ModelNamespaceUris { get; set; } = []; + + public ImmutableArray BindingPlans { get; set; } + = []; + } + + private sealed class ClosureOutcome + { + public ClosureOutcome( + ImmutableArray results, + List projections) + { + Results = results; + Projections = projections; + } + + public ImmutableArray Results { get; } + public List Projections { get; } + } + + private readonly IWotRegistryService m_registry; + private readonly IWotProjectionHost m_host; + private readonly IWotBinderRegistry m_binders; + private readonly IWotDocumentConverter m_converter; + private readonly ImmutableArray m_nodeSetContributors; + private readonly IWotNodeSetResolver? m_nodeSetResolver; + private readonly WotNodeSetConverterOptions m_converterOptions; + private readonly SemaphoreSlim m_mutex = new(1, 1); + + private readonly Dictionary m_closures = + new(StringComparer.Ordinal); + private readonly HashSet m_projectionNamespaceUris = + new(StringComparer.Ordinal); + + private uint m_generation; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs new file mode 100644 index 0000000000..5676824bed --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs @@ -0,0 +1,251 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// A request to refresh (re-project) the registry into the AddressSpace. + /// Mirrors the generated WoTRegistryType.Refresh Method signature. + /// + public sealed class WotRefreshRequest + { + /// + /// Gets or sets the resource selectors; empty selects all resources. + /// + public ImmutableArray Selection { get; set; } + = []; + + /// + /// Gets or sets the refresh options. + /// + public WoTRefreshOptionsDataType Options { get; set; } = new WoTRefreshOptionsDataType(); + + /// + /// Gets or sets the caller's expected registry generation. When non-zero + /// and it does not match the current generation, the refresh is rejected. + /// + public uint ExpectedGeneration { get; set; } + + /// + /// Gets or sets an opaque request id echoed in the summary. + /// + public string RequestId { get; set; } = string.Empty; + } + + /// + /// The detailed result of a refresh, matching the generated + /// WoTRegistryType.Refresh output arguments. + /// + public sealed class WotRefreshResult + { + internal WotRefreshResult( + WoTRefreshSummaryDataType summary, + ImmutableArray results, + uint newGeneration) + { + Summary = summary; + Results = results; + NewGeneration = newGeneration; + } + + /// + /// Gets the overall refresh summary. + /// + public WoTRefreshSummaryDataType Summary { get; } + + /// + /// Gets the per-resource results. + /// + public ImmutableArray Results { get; } + + /// + /// Gets the committed refresh generation. + /// + public uint NewGeneration { get; } + } + + /// + /// The kind of materialization event emitted by the coordinator. + /// + public enum WotMaterializationEventKind + { + /// + /// A refresh completed. + /// + RefreshCompleted, + + /// + /// A resource projection changed state. + /// + Resource, + + /// + /// A resource failed format/compatibility validation. + /// + ValidationFailure, + + /// + /// A resource failed to load/project. + /// + LoadFailure, + + /// + /// A binding failed. + /// + BindingFailure + } + + /// + /// The payload the coordinator raises for each material event. The NodeManager + /// maps it to the generated WoTResourceEventType / + /// WoTValidationFailureEventType / WoTLoadFailureEventType / + /// WoTBindingFailureEventType / WoTRefreshCompletedEventType. + /// + public sealed class WotMaterializationEventArgs : EventArgs + { + internal WotMaterializationEventArgs(WotMaterializationEventKind kind) + { + Kind = kind; + } + + /// + /// Gets the event kind. + /// + public WotMaterializationEventKind Kind { get; } + + /// + /// Gets or sets the affected resource xid. + /// + public string Xid { get; init; } = string.Empty; + + /// + /// Gets or sets the resource id. + /// + public string ResourceId { get; init; } = string.Empty; + + /// + /// Gets or sets the version id. + /// + public string VersionId { get; init; } = string.Empty; + + /// + /// Gets or sets the document kind. + /// + public WoTDocumentKindEnum DocumentKind { get; init; } + + /// + /// Gets or sets the refresh generation. + /// + public uint Generation { get; init; } + + /// + /// Gets or sets the phase reached. + /// + public WoTPhaseEnum Phase { get; init; } + + /// + /// Gets or sets the outcome. + /// + public WoTOutcomeEnum Outcome { get; init; } + + /// + /// Gets or sets the resulting load state. + /// + public WoTLoadStateEnum LoadState { get; init; } + + /// + /// Gets or sets the validation outcome, if any. + /// + public WoTValidationOutcomeDataType? Validation { get; init; } + + /// + /// Gets or sets the failing node id, if any. + /// + public NodeId FailedNodeId { get; init; } + + /// + /// Gets or sets the binding URI, if any. + /// + public string BindingUri { get; init; } = string.Empty; + + /// + /// Gets or sets a human-readable reason/message. + /// + public string Reason { get; init; } = string.Empty; + + /// + /// Gets or sets the refresh summary (RefreshCompleted only). + /// + public WoTRefreshSummaryDataType? Summary { get; init; } + + /// + /// Gets or sets the request id (RefreshCompleted only). + /// + public string RequestId { get; init; } = string.Empty; + } + + /// + /// An that resolves referenced TD/TM + /// documents from a registry snapshot, so a Thing Description synthesized by + /// the converter can pull in the Thing Models it depends on. + /// + internal sealed class SnapshotThingResolver : IWotThingResolver + { + public SnapshotThingResolver(WotRegistrySnapshot snapshot) + { + m_snapshot = snapshot; + } + + /// + /// Resolves a Thing Description or Thing Model reference from the registry snapshot. + /// + public ValueTask ResolveThingAsync( + string reference, + WotResolutionContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WotResource? resource = WotDependencyGraph.Resolve(m_snapshot, reference); + WotResourceVersion? version = resource?.DefaultVersion; + WotResolverResult result = version is null + ? WotResolverResult.NotFound + : WotResolverResult.FromBytes(version.Content); + return new ValueTask(result); + } + + private readonly WotRegistrySnapshot m_snapshot; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs new file mode 100644 index 0000000000..81bb0f3c6f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs @@ -0,0 +1,546 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The per-generation OPC UA target-mapping binding runtime wired onto a + /// freshly imported NodeSet by . + /// It groups executable, target-mapped compiled forms by their resolved + /// target variable, wires either a direct (whole-value) or a structured + /// (field-by-field) handler per group, and owns every channel it lazily + /// opens for the lifetime of the generation. + /// + public sealed class WotProjectionBindingRuntime : IAsyncDisposable + { + internal WotProjectionBindingRuntime( + INodeManagerBuilder builder, + IWotBindingChannelFactory channelFactory, + IWotTargetVariableResolver resolver) + { + m_builder = builder; + m_channelFactory = channelFactory; + m_resolver = resolver; + } + + /// + /// Groups the closure's target-mapped, executable compiled forms by + /// resolved target variable and wires each group. Runs entirely + /// synchronously against the address space (no transport I/O); channel + /// opens are deferred to first use. + /// + /// + /// See . + /// + internal void Wire(ArrayOf bindingPlans) + { + var groups = new Dictionary(); + for (int p = 0; p < bindingPlans.Count; p++) + { + WotBindingPlan plan = bindingPlans[p]; + if (plan is null) + { + continue; + } + foreach (WotCompiledForm form in plan.CompiledForms) + { + if (form is null || form.TargetMapping.IsEmpty || !form.IsExecutable) + { + // Not target-mapped, or validated but not executable: + // out of scope for this runtime. + continue; + } + + if (form.Operation is not (WoTBindingCapabilityEnum.ReadProperty + or WoTBindingCapabilityEnum.WriteProperty or WoTBindingCapabilityEnum.ObserveProperty)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Affordance '{0}' carries an OPC UA target mapping on its '{1}' form, but " + + "only readproperty, writeproperty and observeproperty support target binding.", + form.AffordanceName, + form.OpToken); + } + + BaseVariableState variable = m_resolver.Resolve(m_builder, form.TargetMapping); + if (!groups.TryGetValue(variable.NodeId, out VariableGroup? group)) + { + group = new VariableGroup(variable); + groups.Add(variable.NodeId, group); + } + group.Entries.Add(form); + } + } + + foreach (VariableGroup group in groups.Values) + { + WireGroup(group); + } + } + + /// + /// Disposes every channel this generation successfully opened. + /// Faulted opens have no resource and are silently skipped by + /// ; actual disposal + /// failures are aggregated. + /// + /// + public async ValueTask DisposeAsync() + { + List? errors = null; + foreach (WotBindingChannelSlot slot in m_slots.Values) + { + try + { + await slot.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + (errors ??= []).Add(ex); + } + } + if (errors is { Count: > 0 }) + { + throw new AggregateException( + "One or more WoT projection binding channels failed to dispose.", errors); + } + } + + private void WireGroup(VariableGroup group) + { + bool hasDirect = false; + bool hasField = false; + foreach (WotCompiledForm entry in group.Entries) + { + if (string.IsNullOrEmpty(entry.TargetMapping.FieldPath)) + { + hasDirect = true; + } + else + { + hasField = true; + } + } + if (hasDirect && hasField) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' is mapped both directly ('uav:mapToNodeId' / 'uav:mapToType' alone) and " + + "by field path ('uav:mapByFieldPath'); a target cannot be both a whole-value and a " + + "structured-field target.", + group.Variable.NodeId); + } + + INodeBuilder nodeBuilder = m_builder.Node(group.Variable.NodeId); + if (hasField) + { + WireStructuredGroup(group, nodeBuilder); + } + else + { + WireDirectGroup(group, nodeBuilder); + } + } + + private void WireDirectGroup(VariableGroup group, INodeBuilder nodeBuilder) + { + WotCompiledForm? read = null; + WotCompiledForm? write = null; + foreach (WotCompiledForm entry in group.Entries) + { + switch (entry.Operation) + { + case WoTBindingCapabilityEnum.ObserveProperty: + // Local monitored items sample the async read handler; + // no separate observe bridge is created. + continue; + case WoTBindingCapabilityEnum.ReadProperty: + if (read is not null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has more than one readproperty target mapping.", + group.Variable.NodeId); + } + read = entry; + break; + case WoTBindingCapabilityEnum.WriteProperty: + if (write is not null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has more than one writeproperty target mapping.", + group.Variable.NodeId); + } + write = entry; + break; + } + } + + if (read is not null) + { + nodeBuilder.OnRead(BuildDirectReadHandler(GetOrCreateSlot(read))); + } + if (write is not null) + { + nodeBuilder.OnWrite(BuildDirectWriteHandler(GetOrCreateSlot(write))); + } + } + + /// + /// Wires a structured (field-mapped) group. Target-variable resolution + /// and read/write duplicate-path detection run now, synchronously + /// against the address space; the structure encodeable lookup, root + /// instance validation and per-field + /// calls are + /// deferred to on + /// the first structured read or write, because the target's structure + /// type is not guaranteed to be registered in the shared + /// yet at wiring time (see the class + /// remarks on ). + /// + /// + private void WireStructuredGroup(VariableGroup group, INodeBuilder nodeBuilder) + { + NodeId targetNodeId = group.Variable.NodeId; + + var readByPath = new Dictionary(StringComparer.Ordinal); + var writeByPath = new Dictionary(StringComparer.Ordinal); + foreach (WotCompiledForm entry in group.Entries) + { + string fieldPath = entry.TargetMapping.FieldPath ?? string.Empty; + switch (entry.Operation) + { + case WoTBindingCapabilityEnum.ObserveProperty: + continue; + case WoTBindingCapabilityEnum.ReadProperty: + if (readByPath.ContainsKey(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' field '{1}' has more than one readproperty mapping.", + targetNodeId, + fieldPath); + } + readByPath.Add(fieldPath, entry); + break; + case WoTBindingCapabilityEnum.WriteProperty: + if (writeByPath.ContainsKey(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' field '{1}' has more than one writeproperty mapping.", + targetNodeId, + fieldPath); + } + writeByPath.Add(fieldPath, entry); + break; + } + } + + List<(string Path, WotBindingChannelSlot Slot)> readSlots = [.. readByPath + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))]; + List<(string Path, WotBindingChannelSlot Slot)> writeSlots = [.. writeByPath + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))]; + + var state = new WotStructuredGroupState( + m_builder.Context.EncodeableFactory, + m_builder.Context.NamespaceUris, + group.Variable.DataType, + targetNodeId, + readSlots, + writeSlots); + + if (readSlots.Count > 0) + { + nodeBuilder.OnRead(BuildStructuredReadHandler(state)); + } + if (writeSlots.Count > 0) + { + nodeBuilder.OnWrite(BuildStructuredWriteHandler(state)); + } + } + + private WotBindingChannelSlot GetOrCreateSlot(WotCompiledForm form) + { + if (!m_slots.TryGetValue(form, out WotBindingChannelSlot? slot)) + { + slot = new WotBindingChannelSlot(form, m_channelFactory); + m_slots.Add(form, slot); + } + return slot; + } + + private static NodeValueEventHandlerAsync BuildDirectReadHandler(WotBindingChannelSlot slot) + { + return async (context, node, indexRange, dataEncoding, cancellationToken) => + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue + ? result.Value.SourceTimestamp + : DateTimeUtc.Now; + if (node is BaseVariableState failedVariable) + { + failedVariable.Value = Variant.Null; + failedVariable.StatusCode = result.Status; + failedVariable.Timestamp = failedTimestamp; + } + return new AttributeReadResult( + new ServiceResult(result.Status), + Variant.Null, + result.Status, + failedTimestamp); + } + DataValue value = result.Value; + DateTimeUtc timestamp = value.SourceTimestamp != DateTimeUtc.MinValue + ? value.SourceTimestamp + : DateTimeUtc.Now; + if (node is BaseVariableState variable) + { + variable.Value = value.WrappedValue; + variable.StatusCode = value.StatusCode; + variable.Timestamp = timestamp; + } + return new AttributeReadResult( + ServiceResult.Good, + value.WrappedValue, + value.StatusCode, + timestamp); + }; + } + + private static NodeValueWriteEventHandlerAsync BuildDirectWriteHandler(WotBindingChannelSlot slot) + { + return async (context, node, indexRange, value, cancellationToken) => + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotWriteResult result = await channel + .WriteAsync(new DataValue(value), cancellationToken) + .ConfigureAwait(false); + return new AttributeWriteResult( + result.Success ? ServiceResult.Good : new ServiceResult(result.Status)); + }; + } + + private static NodeValueEventHandlerAsync BuildStructuredReadHandler(WotStructuredGroupState state) + { + return async (context, node, indexRange, dataEncoding, cancellationToken) => + { + WotStructuredGroupResolution resolution = state.EnsureResolved(); + if (!resolution.Success) + { + return new AttributeReadResult( + resolution.Error, Variant.Null, resolution.Error.StatusCode, DateTimeUtc.Now); + } + + IEncodeable rootEncodeable = resolution.RootType!.CreateInstance(); + if (rootEncodeable is not IStructure root) + { + return new AttributeReadResult( + new ServiceResult(StatusCodes.BadConfigurationError), + Variant.Null, + StatusCodes.BadConfigurationError, + DateTimeUtc.Now); + } + + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.ReadFields; + var tasks = new Task<(WotFieldPathPlan Plan, WotReadResult Result)>[fields.Count]; + for (int i = 0; i < fields.Count; i++) + { + tasks[i] = ReadFieldAsync(fields[i].Plan, fields[i].Slot, cancellationToken); + } + (WotFieldPathPlan Plan, WotReadResult Result)[] results = await Task.WhenAll(tasks) + .ConfigureAwait(false); + + foreach ((WotFieldPathPlan _, WotReadResult result) in results) + { + if (!result.Success) + { + DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue + ? result.Value.SourceTimestamp + : DateTimeUtc.Now; + return new AttributeReadResult( + new ServiceResult(result.Status), Variant.Null, result.Status, failedTimestamp); + } + } + + foreach ((WotFieldPathPlan plan, WotReadResult result) in results) + { + IStructure parent = WotStructuredFieldNavigator.CreateOrGetChild(root, plan.IntermediateSegments); + parent[plan.LeafFieldName] = result.Value.WrappedValue; + } + + (StatusCode status, DateTimeUtc timestamp) = AggregateFieldMetadata(results); + return new AttributeReadResult( + ServiceResult.Good, + new Variant(new ExtensionObject(rootEncodeable)), + status, + timestamp); + }; + } + + private static NodeValueWriteEventHandlerAsync BuildStructuredWriteHandler(WotStructuredGroupState state) + { + return async (context, node, indexRange, value, cancellationToken) => + { + WotStructuredGroupResolution resolution = state.EnsureResolved(); + if (!resolution.Success) + { + return new AttributeWriteResult(resolution.Error); + } + + IServiceMessageContext messageContext = context.AsMessageContext(); + if (!value.TryGetValue(out ExtensionObject extensionObject) || + !extensionObject.TryGetValue(out IEncodeable? rootEncodeable, messageContext) || + rootEncodeable is not IStructure root) + { + return new AttributeWriteResult(new ServiceResult(StatusCodes.BadTypeMismatch)); + } + + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.WriteFields; + var tasks = new Task[fields.Count]; + for (int i = 0; i < fields.Count; i++) + { + tasks[i] = WriteFieldAsync( + root, + fields[i].Plan, + fields[i].Slot, + state.TargetNodeId, + messageContext, + cancellationToken); + } + WotWriteResult[] results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (WotWriteResult result in results) + { + if (!result.Success) + { + return new AttributeWriteResult(new ServiceResult(result.Status)); + } + } + return new AttributeWriteResult(ServiceResult.Good); + }; + } + + /// + /// Aggregates the per-field metadata of an all-succeeded structured + /// read into a single status/timestamp pair for the composed value: + /// the first non-default Good status found across the fields (or + /// plain if every field reported it), + /// and the oldest non- source + /// timestamp across the fields (or now, if none carried one). + /// + private static (StatusCode Status, DateTimeUtc Timestamp) AggregateFieldMetadata( + (WotFieldPathPlan Plan, WotReadResult Result)[] results) + { + StatusCode status = StatusCodes.Good; + DateTimeUtc oldest = DateTimeUtc.MinValue; + foreach ((WotFieldPathPlan _, WotReadResult result) in results) + { + StatusCode fieldStatus = result.Value.StatusCode; + if (status == StatusCodes.Good && fieldStatus != StatusCodes.Good) + { + status = fieldStatus; + } + + DateTimeUtc fieldTimestamp = result.Value.SourceTimestamp; + if (fieldTimestamp != DateTimeUtc.MinValue && + (oldest == DateTimeUtc.MinValue || fieldTimestamp < oldest)) + { + oldest = fieldTimestamp; + } + } + return (status, oldest == DateTimeUtc.MinValue ? DateTimeUtc.Now : oldest); + } + + private static async Task<(WotFieldPathPlan Plan, WotReadResult Result)> ReadFieldAsync( + WotFieldPathPlan plan, WotBindingChannelSlot slot, CancellationToken cancellationToken) + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false); + return (plan, result); + } + + private static async Task WriteFieldAsync( + IStructure root, + WotFieldPathPlan plan, + WotBindingChannelSlot slot, + NodeId targetNodeId, + IServiceMessageContext messageContext, + CancellationToken cancellationToken) + { + IStructure parent; + try + { + parent = WotStructuredFieldNavigator.GetExistingChild( + root, + plan.IntermediateSegments, + targetNodeId, + messageContext); + } + catch (ServiceResultException ex) + { + return new WotWriteResult(ex.StatusCode, ex.Message); + } + Variant fieldValue = parent[plan.LeafFieldName]; + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + return await channel.WriteAsync(new DataValue(fieldValue), cancellationToken).ConfigureAwait(false); + } + + private readonly INodeManagerBuilder m_builder; + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly IWotTargetVariableResolver m_resolver; + private readonly Dictionary m_slots = []; + + private sealed class VariableGroup + { + public VariableGroup(BaseVariableState variable) + { + Variable = variable; + } + + public BaseVariableState Variable { get; } + + public List Entries { get; } = []; + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs new file mode 100644 index 0000000000..611eedece6 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs @@ -0,0 +1,107 @@ +/* ======================================================================== + * 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; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The default . It builds + /// a for the closure's prepared + /// binding plans and wires it against the freshly imported predefined + /// nodes. Always available via direct construction (no dependency + /// injection container required) given an + /// — typically the same + /// WotProtocolBinderRegistry instance exposed as + /// . + /// + public sealed class WotProjectionBindingRuntimeFactory : IWotProjectionBindingRuntimeFactory + { + /// + /// Initializes a new projection binding runtime factory. + /// + /// The channel factory used to open live channels. + /// + /// The target-variable resolver. Defaults to a new + /// when null. + /// + public WotProjectionBindingRuntimeFactory( + IWotBindingChannelFactory channelFactory, + IWotTargetVariableResolver? resolver = null) + { + m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory)); + m_resolver = resolver ?? new WotTargetVariableResolver(); + } + + /// + public async ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (bindingPlans.IsEmpty) + { + return null; + } + return await CreateWiredRuntimeAsync(builder, bindingPlans).ConfigureAwait(false); + } + + /// + /// Creates the runtime and wires it, disposing it again if wiring + /// fails so ownership never escapes unwired: the returned instance is + /// always the fully-wired runtime the caller is meant to own. + /// + private async ValueTask CreateWiredRuntimeAsync( + INodeManagerBuilder builder, ArrayOf bindingPlans) + { + var runtime = new WotProjectionBindingRuntime(builder, m_channelFactory, m_resolver); + try + { + runtime.Wire(bindingPlans); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + await runtime.DisposeAsync().ConfigureAwait(false); + throw; + } + return runtime; + } + + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly IWotTargetVariableResolver m_resolver; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs new file mode 100644 index 0000000000..12cf3f7940 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs @@ -0,0 +1,362 @@ +/* ======================================================================== + * 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using Opc.Ua.Encoders; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Decodes the four WoTRegistryType.Refresh input arguments - + /// Selection ([]), + /// Options (), + /// ExpectedGeneration () and RequestId + /// () - into a . Structured + /// arguments are accepted in every form a caller may present them: an already + /// decoded encodeable, an wrapping the + /// encodeable, a binary-encoded ExtensionObject body, and both plain-array and + /// array containers. A value whose type does not match + /// the argument's schema is rejected with + /// rather than silently ignored. + /// + internal static class WotRefreshArguments + { + /// + /// Decodes the Refresh input arguments into a . + /// + /// The raw input argument variants. + /// The message context used to decode encoded bodies. + /// The decoded request on success. + /// + /// on success, or + /// when an argument is present + /// but has the wrong type. + /// + public static ServiceResult TryDecode( + ArrayOf inputArguments, + IServiceMessageContext context, + out WotRefreshRequest request) + { + request = new WotRefreshRequest(); + + ServiceResult selection = TryDecodeSelection( + ArgumentAt(inputArguments, 0), context, + out ImmutableArray selectors); + if (ServiceResult.IsBad(selection)) + { + return selection; + } + + ServiceResult options = TryDecodeStructure( + ArgumentAt(inputArguments, 1), context, + out WoTRefreshOptionsDataType? decodedOptions); + if (ServiceResult.IsBad(options)) + { + return options; + } + + ServiceResult generation = TryDecodeUInt32( + ArgumentAt(inputArguments, 2), out uint expectedGeneration); + if (ServiceResult.IsBad(generation)) + { + return generation; + } + + ServiceResult requestId = TryDecodeString( + ArgumentAt(inputArguments, 3), out string? id); + if (ServiceResult.IsBad(requestId)) + { + return requestId; + } + + request = new WotRefreshRequest + { + Selection = selectors, + Options = decodedOptions ?? new WoTRefreshOptionsDataType(), + ExpectedGeneration = expectedGeneration, + RequestId = id ?? string.Empty + }; + return ServiceResult.Good; + } + + private static Variant ArgumentAt(ArrayOf inputArguments, int index) + { + return index < inputArguments.Count ? inputArguments[index] : Variant.Null; + } + + private static ServiceResult TryDecodeSelection( + Variant value, + IServiceMessageContext context, + out ImmutableArray selectors) + { + selectors = []; + if (value.IsNull) + { + return ServiceResult.Good; + } + + ImmutableArray.Builder builder = + ImmutableArray.CreateBuilder(); + foreach (object? element in Enumerate(value.AsBoxedObject(Variant.BoxingBehavior.Legacy))) + { + if (element is null) + { + continue; + } + ServiceResult status = TryCoerce( + element, context, out WoTResourceSelectorDataType? selector); + if (ServiceResult.IsBad(status) || selector is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Selection argument must be an array of WoTResourceSelectorDataType."); + } + builder.Add(selector); + } + selectors = builder.ToImmutable(); + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeStructure( + Variant value, + IServiceMessageContext context, + out WoTRefreshOptionsDataType? options) + { + options = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + ServiceResult status = TryCoerce( + value.AsBoxedObject(Variant.BoxingBehavior.Legacy), context, out options); + if (ServiceResult.IsBad(status) || options is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Options argument must be a single WoTRefreshOptionsDataType."); + } + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeUInt32(Variant value, out uint result) + { + result = 0; + if (value.IsNull) + { + return ServiceResult.Good; + } + switch (value.AsBoxedObject(Variant.BoxingBehavior.Legacy)) + { + case uint u: + result = u; + return ServiceResult.Good; + case int i when i >= 0: + result = (uint)i; + return ServiceResult.Good; + case long l when l is >= 0 and <= uint.MaxValue: + result = (uint)l; + return ServiceResult.Good; + case ushort us: + result = us; + return ServiceResult.Good; + case byte b: + result = b; + return ServiceResult.Good; + default: + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The ExpectedGeneration argument must be a UInt32."); + } + } + + private static ServiceResult TryDecodeString(Variant value, out string? result) + { + result = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + if (value.AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s) + { + result = s; + return ServiceResult.Good; + } + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The RequestId argument must be a String."); + } + + private static IEnumerable Enumerate(object? boxed) + { + switch (boxed) + { + case null: + yield break; + case ExtensionObject single: + yield return single; + break; + case IConvertableToArray convertible: + var array = convertible.ToArray(); + if (array is not null) + { + foreach (object? item in array) + { + yield return item; + } + } + break; + case IEnumerable enumerable when boxed is not string: + foreach (object? item in enumerable) + { + yield return item; + } + break; + default: + yield return boxed; + break; + } + } + + private static ServiceResult TryCoerce( + object? element, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + switch (element) + { + case T typed: + value = typed; + return ServiceResult.Good; + case ExtensionObject extension: + return TryDecodeExtensionObject(extension, context, out value); + case IEncodeable encodeable: + return TryDecodeExtensionObject( + new ExtensionObject(encodeable), + context, + out value); + default: + return StatusCodes.BadInvalidArgument; + } + } + + private static ServiceResult TryDecodeExtensionObject( + ExtensionObject extension, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + if (extension.IsNull) + { + return StatusCodes.BadInvalidArgument; + } +#pragma warning disable IDE0018, IDE0059 // must stay a separate, non-inlined out-var (see IDE0001 note below) + T? typed = default; +#pragma warning disable IDE0001 // explicit avoids CS8631/CS8634: inference from a nullable out-var picks 'T?' + if (new Variant(extension).TryGetStructure(context, out typed)) +#pragma warning restore IDE0001, IDE0018, IDE0059 + { + value = typed; + return ServiceResult.Good; + } + if (typeof(T) == typeof(WoTRefreshOptionsDataType) && + extension.TryGetValue(out Structure? structure, context) && + structure is not null && + TryDecodeDynamicOptions(structure, out WoTRefreshOptionsDataType? options)) + { + value = (T)(IEncodeable)options!; + return ServiceResult.Good; + } + if (extension.TryGetAsBinary(out ByteString body, context) && !body.IsNull) + { + try + { + using var decoder = new BinaryDecoder(body.Span.ToArray(), context); + value = new T(); + value.Decode(decoder); + return ServiceResult.Good; + } + catch (Exception ex) when (ex is ServiceResultException or FormatException) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The encoded argument body could not be decoded."); + } + } + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The encoded argument body could not be decoded."); + } + + private static bool TryDecodeDynamicOptions( + Structure structure, + out WoTRefreshOptionsDataType? options) + { + options = null; + if (structure.TypeId != DataTypeIds.WoTRefreshOptionsDataType || + structure.BinaryEncodingId != ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary) + { + return false; + } + options = new WoTRefreshOptionsDataType + { + Atomicity = GetEnum(structure, "Atomicity"), + Force = GetBoolean(structure, "Force"), + DryRun = GetBoolean(structure, "DryRun"), + IncludeDependents = GetBoolean(structure, "IncludeDependents"), + DeletePolicy = GetEnum(structure, "DeletePolicy"), + MaxParallelism = GetUInt32(structure, "MaxParallelism"), + Timeout = GetDouble(structure, "Timeout") + }; + return true; + } + + private static bool GetBoolean(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out bool value) && value; + + private static double GetDouble(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out double value) ? value : 0; + + private static TEnum GetEnum(Structure structure, string fieldName) + where TEnum : struct + { + return structure[fieldName].TryGetValue(out int value) + ? (TEnum)(object)value + : default; + } + + private static uint GetUInt32(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out uint value) ? value : 0; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs new file mode 100644 index 0000000000..473c6090e8 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs @@ -0,0 +1,275 @@ +/* ======================================================================== + * 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.Immutable; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// One non-leaf segment of a validated uav:mapByFieldPath path: the + /// field name to descend through and the encodeable type of the nested + /// structure it holds. + /// + internal sealed class WotFieldPathSegment + { + public WotFieldPathSegment(string name, IEncodeableType nestedType) + { + Name = name; + NestedType = nestedType; + } + + public string Name { get; } + + public IEncodeableType NestedType { get; } + } + + /// + /// A validated, pre-resolved walk from a structured target's root down to + /// one leaf field, built once during activation so repeated reads/writes + /// never re-walk metadata. + /// + internal sealed class WotFieldPathPlan + { + public WotFieldPathPlan(ImmutableArray intermediateSegments, string leafFieldName) + { + IntermediateSegments = intermediateSegments; + LeafFieldName = leafFieldName; + } + + public ImmutableArray IntermediateSegments { get; } + + public string LeafFieldName { get; } + } + + /// + /// Validates uav:mapByFieldPath paths against + /// metadata and navigates + /// instances while composing (read direction) or + /// extracting (write direction) a structured target value. No reflection + /// and no public API are used: nested structures are + /// read and written exclusively through and + /// . + /// + internal static class WotStructuredFieldNavigator + { + /// + /// Validates a slash-separated field path against the root type's + /// structure definition tree and returns the pre-resolved plan. + /// + /// + /// The path is empty, contains an empty segment, references an unknown + /// field, traverses an array-valued intermediate field, or traverses an + /// intermediate field whose DataType is not a structure type. + /// + /// + public static WotFieldPathPlan BuildPlan( + IEncodeableFactory factory, + NamespaceTable namespaceUris, + IEncodeableType rootType, + string fieldPath, + NodeId targetNodeId) + { + if (string.IsNullOrWhiteSpace(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' for target '{0}' must not be empty.", + targetNodeId); + } + + string[] segments = fieldPath.Split('/'); + ImmutableArray.Builder intermediate = + ImmutableArray.CreateBuilder(); + IEncodeableType currentType = rootType; + + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + if (string.IsNullOrEmpty(segment)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' contains an empty segment.", + fieldPath, + targetNodeId); + } + + StructureDefinition definition = GetStructureDefinition( + currentType, namespaceUris, fieldPath, targetNodeId); + StructureField? field = FindField(definition, segment) ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' references unknown field '{2}'.", + fieldPath, + targetNodeId, + segment); + + bool isLast = i == segments.Length - 1; + if (isLast) + { + return new WotFieldPathPlan(intermediate.ToImmutable(), segment); + } + + if (field.ValueRank != ValueRanks.Scalar) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses array-valued field " + + "'{2}' (ValueRank {3}); only scalar structures can be traversed.", + fieldPath, + targetNodeId, + segment, + field.ValueRank); + } + + var nestedTypeId = NodeId.ToExpandedNodeId(field.DataType, namespaceUris); + if (!factory.TryGetEncodeableType(nestedTypeId, out IEncodeableType? nestedType) || + nestedType.CreateInstance() is not IStructure) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses field '{2}' whose " + + "DataType '{3}' is not a registered structure type.", + fieldPath, + targetNodeId, + segment, + field.DataType); + } + + intermediate.Add(new WotFieldPathSegment(segment, nestedType)); + currentType = nestedType; + } + + // Unreachable: segments.Length > 0 is guaranteed by the empty-path + // check above, so the loop always returns via the isLast branch. + throw new InvalidOperationException("Field path resolution did not reach a leaf segment."); + } + + /// + /// Navigates from to the parent of the leaf + /// field, creating any missing nested structure instance along the + /// way. Used when composing a fresh structured value for a read. + /// + /// + public static IStructure CreateOrGetChild( + IStructure root, ImmutableArray intermediateSegments) + { + IStructure current = root; + foreach (WotFieldPathSegment segment in intermediateSegments) + { + Variant existing = current[segment.Name]; + if (existing.TryGetValue(out ExtensionObject extensionObject) && + extensionObject.TryGetValue(out IEncodeable? existingEncodeable) && + existingEncodeable is IStructure existingChild) + { + current = existingChild; + continue; + } + + IEncodeable createdChild = segment.NestedType.CreateInstance(); + if (createdChild is not IStructure child) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Field '{0}' could not be instantiated as a structure.", + segment.Name); + } + current[segment.Name] = new Variant(new ExtensionObject(createdChild)); + current = child; + } + return current; + } + + /// + /// Navigates from to the parent of the leaf + /// field without creating anything. Used when extracting a field from + /// an incoming structured value for a write. + /// + /// + /// An intermediate field is missing, null, or not the expected + /// structure type. + /// + public static IStructure GetExistingChild( + IStructure root, + ImmutableArray intermediateSegments, + NodeId targetNodeId, + IServiceMessageContext messageContext) + { + IStructure current = root; + foreach (WotFieldPathSegment segment in intermediateSegments) + { + Variant existing = current[segment.Name]; + if (!existing.TryGetValue(out ExtensionObject extensionObject) || + !extensionObject.TryGetValue(out IEncodeable? existingEncodeable, messageContext) || + existingEncodeable is not IStructure existingChild) + { + throw ServiceResultException.Create( + StatusCodes.BadStructureMissing, + "Target '{0}' is missing the nested structure at field '{1}'.", + targetNodeId, + segment.Name); + } + current = existingChild; + } + return current; + } + + private static StructureDefinition GetStructureDefinition( + IEncodeableType type, NamespaceTable namespaceUris, string fieldPath, NodeId targetNodeId) + { + if (type is not IDataTypeDefinitionSource source || + source.GetDataTypeDefinition(namespaceUris) is not StructureDefinition definition) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses type '{2}', which does " + + "not expose a structure definition.", + fieldPath, + targetNodeId, + type.XmlName); + } + return definition; + } + + private static StructureField? FindField(StructureDefinition definition, string name) + { + ArrayOf fields = definition.Fields; + for (int i = 0; i < fields.Count; i++) + { + StructureField field = fields[i]; + if (string.Equals(field.Name, name, StringComparison.Ordinal)) + { + return field; + } + } + return null; + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs new file mode 100644 index 0000000000..a26a3d82ec --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs @@ -0,0 +1,239 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Holds the not-yet-resolved slots for one structured (field-mapped) + /// target variable and lazily resolves the target's structure + /// plus every field's + /// on first use. + /// + /// Resolution cannot run while a runtime NodeSet is being wired: the + /// generation's binding runtime is wired by + /// , + /// which completes before NodeManagerLifecycle.RefreshComplexTypesAsync + /// registers the server's custom structure types into the shared + /// . Deferring resolution to the first + /// structured read or write lets that same factory instance — mutated in + /// place by RefreshComplexTypesAsync before the NodeManager is + /// published — already carry the type by the time it is needed. + /// + /// + /// Thread-safe and retryable: concurrent first use resolves at most once + /// under a single lock; a successful resolution is cached forever, and a + /// failed resolution (the type is still unavailable, or validation still + /// fails) is retried, uncached, on every subsequent call. + /// + /// + internal sealed class WotStructuredGroupState + { + /// + /// Initializes a new lazily-resolved structured group. + /// + /// + /// The server's . Captured by reference + /// so a later mutation (type registration) is visible to resolution. + /// + /// The node manager's namespace table. + /// The target variable's declared DataType. + /// The target variable's NodeId, for diagnostics. + /// + /// The read-direction field paths and their channel slots, already + /// duplicate-checked and ordered by . + /// + /// + /// The write-direction field paths and their channel slots, already + /// duplicate-checked and ordered by . + /// + public WotStructuredGroupState( + IEncodeableFactory factory, + NamespaceTable namespaceUris, + NodeId dataTypeId, + NodeId targetNodeId, + List<(string Path, WotBindingChannelSlot Slot)> readSlots, + List<(string Path, WotBindingChannelSlot Slot)> writeSlots) + { + m_factory = factory; + m_namespaceUris = namespaceUris; + m_dataTypeId = dataTypeId; + TargetNodeId = targetNodeId; + m_readSlots = readSlots; + m_writeSlots = writeSlots; + } + + /// + /// Gets the target variable's NodeId. + /// + public NodeId TargetNodeId { get; } + + /// + /// Resolves the structure type and every field's navigation plan on + /// first call, and returns the cached result on every later call. + /// A failed attempt resolves nothing permanently: the next call + /// retries from scratch against the (possibly by-then-populated) + /// factory. + /// + /// + /// On success, together with the + /// resolved root type and field plans. On failure, the deterministic + /// describing why resolution could not + /// complete, with empty field plan lists. + /// + public WotStructuredGroupResolution EnsureResolved() + { + lock (m_gate) + { + if (m_resolution is { } cached) + { + return cached; + } + + try + { + var rootTypeId = NodeId.ToExpandedNodeId(m_dataTypeId, m_namespaceUris); + if (!m_factory.TryGetEncodeableType(rootTypeId, out IEncodeableType? rootType) || + rootType.CreateInstance() is not IStructure) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has DataType '{1}', which is not a registered structure type; " + + "'uav:mapByFieldPath' requires a structured target.", + TargetNodeId, + m_dataTypeId); + } + + var resolution = new WotStructuredGroupResolution( + ServiceResult.Good, + rootType, + BuildFieldPlans(rootType, m_readSlots), + BuildFieldPlans(rootType, m_writeSlots)); + + // Cache only the successful resolution; a failure below is + // never cached so the next first use retries. + m_resolution = resolution; + return resolution; + } + catch (ServiceResultException ex) + { + return WotStructuredGroupResolution.Failed(new ServiceResult(ex)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return WotStructuredGroupResolution.Failed(new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText( + $"Target '{TargetNodeId}' structure mapping could not be resolved: {ex.Message}"))); + } + } + } + + private List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> BuildFieldPlans( + IEncodeableType rootType, List<(string Path, WotBindingChannelSlot Slot)> slots) + { + var plans = new List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)>(slots.Count); + foreach ((string path, WotBindingChannelSlot slot) in slots) + { + plans.Add(( + WotStructuredFieldNavigator.BuildPlan(m_factory, m_namespaceUris, rootType, path, TargetNodeId), + slot)); + } + return plans; + } + + private readonly IEncodeableFactory m_factory; + private readonly NamespaceTable m_namespaceUris; + private readonly NodeId m_dataTypeId; + private readonly List<(string Path, WotBindingChannelSlot Slot)> m_readSlots; + private readonly List<(string Path, WotBindingChannelSlot Slot)> m_writeSlots; + private readonly Lock m_gate = new(); + private WotStructuredGroupResolution? m_resolution; + } + + /// + /// The outcome of : + /// either the resolved structure type with its field plans, or the + /// deterministic failure status naming why resolution could not complete. + /// + internal sealed class WotStructuredGroupResolution + { + /// + /// Initializes a new resolution outcome. + /// + public WotStructuredGroupResolution( + ServiceResult error, + IEncodeableType? rootType, + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> readFields, + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> writeFields) + { + Error = error; + RootType = rootType; + ReadFields = readFields; + WriteFields = writeFields; + } + + /// + /// Gets the failure status, or when + /// resolution succeeded. + /// + public ServiceResult Error { get; } + + /// + /// Gets whether resolution succeeded. + /// + public bool Success => ServiceResult.IsGood(Error); + + /// + /// Gets the resolved structure type, or null on failure. + /// + public IEncodeableType? RootType { get; } + + /// + /// Gets the resolved read-direction field plans; empty on failure. + /// + public List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> ReadFields { get; } + + /// + /// Gets the resolved write-direction field plans; empty on failure. + /// + public List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> WriteFields { get; } + + /// + /// Creates a failed resolution outcome carrying only the error. + /// + public static WotStructuredGroupResolution Failed(ServiceResult error) + { + return new(error, null, [], []); + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj index 9cd80c1d6e..8c7189aa07 100644 --- a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj +++ b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj @@ -5,24 +5,34 @@ $(LibTargetFrameworks) $(PackagePrefix).Opc.Ua.WotCon.Server Opc.Ua.WotCon.Server - $(NoWarn);CS1591 enable - OPC UA WoT Connectivity (OPC 10100-1) server class library + OPC UA WoT Connectivity 1.1 server class library — hosts the deprecated OPC 10100-1 v1.02 asset-management surface and the registry-first materialization runtime. true NugetREADME.md true true true $(NoWarn);SYSLIB1100;SYSLIB1101;IL2026;IL3050 @@ -36,6 +46,7 @@ + diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs new file mode 100644 index 0000000000..a959314583 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs @@ -0,0 +1,587 @@ +/* ======================================================================== + * 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 Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// The stable NodeManager that exposes the WoT Connectivity 1.1 registry + /// (WoTRegistry) and its xRegistry-derived group structure. It hosts + /// the injected and + /// : content mutations trigger a + /// coordinator refresh that projects TD/TM closures as separate runtime + /// NodeManagers, so this manager stays stable while projections come and go. + /// The generated Refresh Method is wired to the coordinator; the + /// coordinator's events are re-emitted as the generated registry event types. + /// + public sealed class WotRegistryNodeManager : AsyncCustomNodeManager + { + /// + /// Initializes a new registry NodeManager. + /// + public WotRegistryNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + : base( + server, + configuration, + server.Telemetry.CreateLogger(), + Namespaces.WotCon, + XRegistryWellKnown.XRegistryNamespaceUri) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + Coordinator.StrictBindings = options.StrictBindings; + Coordinator.RetirementPolicy = options.RetirementPolicy; + Coordinator.ServerNamespaceUris = server.NamespaceUris; + m_projection = new WotRegistryProjection(this, Registry, m_options); + } + + /// + /// Gets the hosted registry service. + /// + public IWotRegistryService Registry { get; } + + /// + /// Gets the hosted materialization coordinator. + /// + public WotMaterializationCoordinator Coordinator { get; } + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + // Load the xRegistry base plus the combined WoT-Con model, then keep + // only the additive registry slice. The incorporated (deprecated) + // OPC 10100-1 v1.02 nodes are owned by WotConnectivityNodeManager, so + // the two managers never claim the same static model node twice. + NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaXRegistry(context) + .AddOpcUaWotCon(context); + WotConModelPartition.RetainRegistryNodes(nodes, context); + return new ValueTask(nodes); + } + + /// + protected override ValueTask AddBehaviourToPredefinedNodeAsync( + ISystemContext context, + NodeState predefinedNode, + CancellationToken cancellationToken = default) + { + var registryNodeId = ExpandedNodeId.ToNodeId( + ObjectIds.WoTRegistry, Server.NamespaceUris); + if (predefinedNode is BaseObjectState registry && + registry.NodeId == registryNodeId) + { + m_registryNode = registry; + registry.EventNotifier = EventNotifiers.SubscribeToEvents; + EnsureRegistryManagementMethods(context, registry); + WireRefreshMethod(registry); + ApplyRegistrySettings(context, registry); + } + return new ValueTask(predefinedNode); + } + + private void EnsureRegistryManagementMethods( + ISystemContext context, BaseObjectState registry) + { + if (registry is not RegistryState typed) + { + return; + } + // Instantiate the optional xRegistry CreateGroup/GetOrCreateGroup + // Methods on the well-known singleton. The generated Add helpers mint + // fresh per-instance NodeIds (through the NodeManager's NodeIdFactory) + // and rebase the argument references so the Methods never collide with + // the RegistryType Method declarations. + typed.AddCreateGroup(context) + .AddGetOrCreateGroup(context); + WotRegistryProjection.LinkMethodArguments(typed.CreateGroup, context); + WotRegistryProjection.LinkMethodArguments(typed.GetOrCreateGroup, context); + + // Instantiate the optional Labels (AttributesType) container and its + // AddAttribute/RemoveAttribute Methods here, before this predefined + // node's subtree is registered by the base class's + // CreateAddressSpaceAsync: only children present at that point are + // swept into the NodeManager's node table. WotRegistryProjection + // wires the actual Method handlers later (see AttachAsync). + typed.AddLabels(context); + if (typed.Labels is not null) + { + typed.Labels.AddAddAttribute(context); + typed.Labels.AddRemoveAttribute(context); + WotRegistryProjection.LinkMethodArguments(typed.Labels, context); + } + } + + /// + public override async ValueTask CreateAddressSpaceAsync( + IDictionary> externalReferences, + CancellationToken cancellationToken = default) + { + await base.CreateAddressSpaceAsync(externalReferences, cancellationToken) + .ConfigureAwait(false); + + // Chain WoTRegistry into the Server's notifier tree so its events + // reach subscribing clients. The generated WoTRegistry Object already + // declares the inverse HasNotifier to the Server object, so only the + // forward reference on the Server side is added here. + if (m_registryNode is not null) + { + if (externalReferences.TryGetValue( + Ua.ObjectIds.Server, out IList? serverRefs) || + (serverRefs = EnsureList(externalReferences, Ua.ObjectIds.Server)) != null) + { + serverRefs.Add(new NodeStateReference( + Ua.ReferenceTypeIds.HasNotifier, false, m_registryNode.NodeId)); + } + } + + await Registry.InitializeAsync(cancellationToken).ConfigureAwait(false); + Registry.Changed += OnRegistryChanged; + Coordinator.Event += OnCoordinatorEvent; + + // Materialize the browseable group/resource projection, then project + // whatever is already persisted into the AddressSpace. + if (m_registryNode is not null) + { + await m_projection.AttachAsync(m_registryNode, cancellationToken) + .ConfigureAwait(false); + } + await SafeRefreshAsync("startup").ConfigureAwait(false); + await m_projection.ReconcileAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DeleteAddressSpaceAsync( + CancellationToken cancellationToken = default) + { + Registry.Changed -= OnRegistryChanged; + Coordinator.Event -= OnCoordinatorEvent; + await Coordinator.RemoveAllAsync(cancellationToken).ConfigureAwait(false); + m_projection.Dispose(); + await base.DeleteAddressSpaceAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + m_projection.Dispose(); + m_refreshGate.Dispose(); + } + base.Dispose(disposing); + } + + private void WireRefreshMethod(BaseObjectState registry) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (registry.FindChild(SystemContext, new QualifiedName(BrowseNames.Refresh, ns)) + is MethodState refresh) + { + refresh.OnCallMethod2Async = OnRefreshAsync; + } + } + + private void ApplyRegistrySettings(ISystemContext context, BaseObjectState registry) + { + SetChildValue(registry, "AutoRefresh", new Variant(m_options.AutoRefresh)); + SetChildValue(registry, "RefreshMode", + new Variant((int)WoTRefreshModeEnum.EventDriven)); + SetChildValue(registry, "VocabularyVersion", + new Variant(Wot.WotNodeSetConverter.VocabularyNamespace)); + ApplyBindingCapabilities(registry); + } + + private void ApplyBindingCapabilities(BaseObjectState registry) + { + IReadOnlyList caps = Coordinator.BindingCapabilities; + if (caps.Count == 0) + { + return; + } + var encoded = new ExtensionObject[caps.Count]; + for (int i = 0; i < caps.Count; i++) + { + encoded[i] = new ExtensionObject(caps[i]); + } + SetChildValue(registry, "SelectedBindings", + new Variant(new ArrayOf(encoded))); + } + + private async ValueTask OnRefreshAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken) + { + ServiceResult access = CheckManagementAccess(context, "Refresh"); + if (ServiceResult.IsBad(access)) + { + return access; + } + + ServiceResult decoded = WotRefreshArguments.TryDecode( + inputArguments, Server.MessageContext, out WotRefreshRequest request); + if (ServiceResult.IsBad(decoded)) + { + return decoded; + } + + if (!await m_refreshGate + .WaitAsync(0, cancellationToken) + .ConfigureAwait(false)) + { + return StatusCodes.BadServerTooBusy; + } + try + { + WotRefreshResult result = await Coordinator + .RefreshAsync(request, cancellationToken).ConfigureAwait(false); + + outputArguments.Clear(); + outputArguments.Add(Variant.FromStructure(result.Summary)); + outputArguments.Add(Variant.FromStructure(result.Results.ToArrayOf())); + outputArguments.Add(new Variant(result.NewGeneration)); + return ServiceResult.Good; + } + finally + { + m_refreshGate.Release(); + } + } + + private void OnRegistryChanged(object? sender, WotRegistryChangedEventArgs e) + { + // Keep the browseable projection synchronized on every change, + // including projection-only callbacks (which must never re-trigger + // materialization). + _ = SafeReconcileAsync(); + if (e.ProjectionOnly || !m_options.AutoRefresh) + { + return; + } + // Content mutation: re-project asynchronously without blocking the caller. + _ = SafeRefreshAsync("auto"); + } + + private void OnCoordinatorEvent(object? sender, WotMaterializationEventArgs e) + { + if (m_registryNode is null) + { + return; + } + try + { + NodeState source = EventSourceFor(e); + BaseEventState? evt = BuildEvent(e, source); + if (evt is not null) + { + source.ReportEvent(SystemContext, evt); + } + } + catch (Exception ex) + { + m_logger.FailedToReportMaterializationEvent(ex); + } + } + + private NodeState EventSourceFor(WotMaterializationEventArgs e) + { + // Resource lifecycle failures are sourced at the specific resource + // node; the registry object remains the summary source for the + // refresh-completed event. + if (e.Kind == WotMaterializationEventKind.RefreshCompleted) + { + return m_registryNode!; + } + return m_projection.EventSourceFor(e.Xid); + } + + private BaseEventState? BuildEvent(WotMaterializationEventArgs e, NodeState source) + { + switch (e.Kind) + { + case WotMaterializationEventKind.RefreshCompleted: + { + var evt = new WoTRefreshCompletedEventState(m_registryNode); + InitializeEvent(evt, source, "RefreshCompleted"); + // Summary/RequestId/NewGeneration come from the coordinator's + // refresh summary, which is produced from the registry snapshot. + if (e.Summary is not null) + { + SetEventStruct(evt, BrowseNames.Summary, e.Summary); + } + SetEventValue(evt, BrowseNames.RequestId, new Variant(e.RequestId)); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + return evt; + } + case WotMaterializationEventKind.ValidationFailure: + { + var evt = new WoTValidationFailureEventState(source); + InitializeEvent(evt, source, "ValidationFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + if (e.Validation is not null) + { + SetEventStruct(evt, BrowseNames.ValidationOutcome, e.Validation); + } + return evt; + } + case WotMaterializationEventKind.LoadFailure: + { + var evt = new WoTLoadFailureEventState(source); + InitializeEvent(evt, source, "LoadFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventEnum(evt, BrowseNames.LoadState, e.LoadState); + SetEventValue( + evt, BrowseNames.FailedNodeId, new Variant(e.FailedNodeId)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + case WotMaterializationEventKind.BindingFailure: + { + var evt = new WoTBindingFailureEventState(source); + InitializeEvent(evt, source, "BindingFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventValue(evt, BrowseNames.BindingUri, new Variant(e.BindingUri)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + default: + { + var evt = new WoTResourceEventState(source); + InitializeEvent(evt, source, "Resource: " + e.ResourceId); + PopulateResourceEventFields(evt, e); + return evt; + } + } + } + + private void InitializeEvent(BaseEventState evt, NodeState source, string message) + { + evt.Initialize( + SystemContext, + source: source, + EventSeverity.Medium, + new LocalizedText(message)); + evt.SetChildValue( + SystemContext, Ua.BrowseNames.SourceName, + source.DisplayName.Text ?? "WoTRegistry", false); + } + + /// + /// Populates the identity/lifecycle fields shared by every + /// WoTResourceEventType (and its concrete subtypes) from the + /// coordinator's event arguments. + /// + private void PopulateResourceEventFields( + BaseEventState evt, WotMaterializationEventArgs e) + { + SetEventValue(evt, BrowseNames.Xid, new Variant(e.Xid)); + SetEventValue(evt, BrowseNames.ResourceId, new Variant(e.ResourceId)); + SetEventValue(evt, BrowseNames.VersionId, new Variant(e.VersionId)); + SetEventEnum(evt, BrowseNames.DocumentKind, e.DocumentKind); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + SetEventEnum(evt, BrowseNames.Phase, e.Phase); + SetEventEnum(evt, BrowseNames.Outcome, e.Outcome); + } + + private void SetEventValue(BaseEventState evt, string browseName, Variant value) + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + } + + private void SetEventEnum(BaseEventState evt, string browseName, TEnum value) + where TEnum : struct, Enum + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value); + } + + private void SetEventStruct(BaseEventState evt, string browseName, TStruct value) + where TStruct : IEncodeable + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + } + + private QualifiedName WoTQualifiedName(string browseName) + { + return new(browseName, (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon)); + } + + private async Task SafeReconcileAsync() + { + try + { + await m_projection.ReconcileAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.RegistryProjectionReconcileFailed(ex); + } + } + + private async Task SafeRefreshAsync(string reason) + { + try + { + await Coordinator.RefreshAsync(new WotRefreshRequest { RequestId = reason }) + .ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.RegistryRefreshFailed(ex, reason); + } + } + + internal ServiceResult CheckManagementAccess(ISystemContext context, string operation) + { + if (context is not SessionSystemContext { OperationContext: OperationContext operationContext }) + { + // Local / programmatic call: allowed. + return ServiceResult.Good; + } + WotManagementAccessPolicy policy = m_options.ManagementAccess; + MessageSecurityMode securityMode = operationContext.ChannelContext? + .EndpointDescription?.SecurityMode ?? + MessageSecurityMode.None; + if (securityMode != policy.MinimumSecurityMode) + { + m_logger.ManagementCallDeniedSecurityMode(operation, securityMode); + return StatusCodes.BadUserAccessDenied; + } + IUserIdentity? identity = operationContext.UserIdentity; + if (identity is null || + (!policy.AllowAnonymous && identity.TokenType == UserTokenType.Anonymous)) + { + m_logger.ManagementCallDeniedAnonymousIdentity(operation); + return StatusCodes.BadUserAccessDenied; + } + if (!identity.GrantedRoleIds.Contains(policy.RequiredRoleId)) + { + m_logger.ManagementCallDeniedMissingRole(operation); + return StatusCodes.BadUserAccessDenied; + } + return ServiceResult.Good; + } + + private static IList EnsureList( + IDictionary> externalReferences, NodeId nodeId) + { + if (!externalReferences.TryGetValue(nodeId, out IList? list)) + { + list = []; + externalReferences[nodeId] = list; + } + return list; + } + + private void SetChildValue(BaseObjectState parent, string browseName, Variant value) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (parent.FindChild(SystemContext, new QualifiedName(browseName, ns)) + is BaseVariableState variable) + { + variable.Value = value; + } + } + + private readonly WotRegistryServerOptions m_options; + private readonly WotRegistryProjection m_projection; + private readonly SemaphoreSlim m_refreshGate = new(1, 1); + private BaseObjectState? m_registryNode; + } + + /// + /// Holds source-generated log messages emitted by the WoT registry NodeManager component. + /// + internal static partial class WotRegistryNodeManagerLog + { + /// + /// Logs that raising a WoT materialization event failed. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 0, Level = LogLevel.Warning, + Message = "Failed to report WoT materialization event.")] + public static partial void FailedToReportMaterializationEvent(this ILogger logger, Exception ex); + + /// + /// Logs that reconciling the registry projection failed. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 1, Level = LogLevel.Warning, + Message = "WoT registry projection reconcile failed.")] + public static partial void RegistryProjectionReconcileFailed(this ILogger logger, Exception ex); + + /// + /// Logs that a registry refresh failed for the supplied reason. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 2, Level = LogLevel.Warning, + Message = "WoT registry refresh ({Reason}) failed.")] + public static partial void RegistryRefreshFailed(this ILogger logger, Exception ex, string reason); + + /// + /// Logs that a registry management call was denied because channel security was too weak. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 3, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': channel security mode {Mode} is too low.")] + public static partial void ManagementCallDeniedSecurityMode( + this ILogger logger, + string operation, + MessageSecurityMode mode); + + /// + /// Logs that a registry management call was denied because the caller was anonymous. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 4, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': anonymous or missing identity.")] + public static partial void ManagementCallDeniedAnonymousIdentity(this ILogger logger, string operation); + + /// + /// Logs that a registry management call was denied because the caller lacks the required role. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 5, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': caller lacks required role.")] + public static partial void ManagementCallDeniedMissingRole(this ILogger logger, string operation); + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs new file mode 100644 index 0000000000..8ea2865875 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs @@ -0,0 +1,90 @@ +/* ======================================================================== + * 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; +using Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// that produces the stable + /// configured with the shared registry + /// service and materialization coordinator. + /// + public sealed class WotRegistryNodeManagerFactory : IAsyncNodeManagerFactory + { + /// + /// Creates a new factory. + /// + public WotRegistryNodeManagerFactory( + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + } + + /// + public ArrayOf NamespacesUris => new string[] + { + Namespaces.WotCon, + XRegistryWellKnown.XRegistryNamespaceUri + }; + + /// + public ValueTask CreateAsync( + IServerInternal server, + ApplicationConfiguration configuration, + CancellationToken cancellationToken = default) + { + // CA2000 cannot model ownership transfer through ValueTask. + // TODO: Remove this suppression when CA2000 recognizes factory ownership transfer. +#pragma warning disable CA2000 + IAsyncNodeManager nodeManager = new WotRegistryNodeManager( + server, + configuration, + m_options, + m_registry, + m_coordinator); +#pragma warning restore CA2000 + return new ValueTask(nodeManager); + } + + private readonly WotRegistryServerOptions m_options; + private readonly IWotRegistryService m_registry; + private readonly WotMaterializationCoordinator m_coordinator; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs new file mode 100644 index 0000000000..7c6d0081a0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs @@ -0,0 +1,1160 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Materializes the WoT Connectivity 1.1 registry snapshot as browseable + /// xRegistry Objects beneath the stable WoTRegistry node: + /// ThingDescriptionGroupType/ThingModelGroupType group Objects + /// and their ThingDescriptionFileType/ThingModelFileType + /// document resources. Every group and resource is (re)created, updated and + /// removed to mirror the immutable snapshot, with deterministic NodeIds + /// derived from the registry Xid, notifier references up the + /// registry → group → resource chain, and the xRegistry CRUD / + /// FileType / document Methods wired to the injected registry service. + /// + internal sealed class WotRegistryProjection : IDisposable + { + public WotRegistryProjection( + WotRegistryNodeManager manager, + IWotRegistryService registry, + WotRegistryServerOptions options) + { + m_manager = manager ?? throw new ArgumentNullException(nameof(manager)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_modelNs = (ushort)manager.Server.NamespaceUris.GetIndex(Namespaces.WotCon); + } + + /// + /// Binds the projection to the well-known registry Object, wires the + /// registry-level CreateGroup/GetOrCreateGroup Methods, materializes + /// and wires its Labels (AttributesType) container, and performs the + /// first reconcile. + /// + /// + public async ValueTask AttachAsync(BaseObjectState registryNode, CancellationToken ct) + { + m_registryNode = registryNode ?? throw new ArgumentNullException(nameof(registryNode)); + registryNode.EventNotifier = EventNotifiers.SubscribeToEvents; + WireMethod(registryNode, XRegistry.BrowseNames.CreateGroup, OnCreateGroupAsync); + WireMethod(registryNode, XRegistry.BrowseNames.GetOrCreateGroup, OnGetOrCreateGroupAsync); + if (registryNode is RegistryState registryTyped) + { + registryTyped.AddLabels(m_manager.SystemContext); + WireLabelsContainer( + registryTyped.Labels, OnAddRegistryLabelAsync, OnRemoveRegistryLabelAsync); + LinkMethodArguments(registryTyped.Labels, m_manager.SystemContext); + } + await ReconcileAsync(ct).ConfigureAwait(false); + } + + /// + /// Finds the browseable resource node used as an event source, or the + /// registry node when the resource is unknown. + /// + public NodeState EventSourceFor(string? xid) + { + if (!string.IsNullOrEmpty(xid) && + m_resourcesByXid.TryGetValue(xid!, out WoTDocumentState? node)) + { + return node; + } + return m_registryNode!; + } + + /// + /// Reconciles the browseable projection with the current registry + /// snapshot: creates, updates and removes group and resource nodes. + /// Never re-triggers materialization. + /// + public async ValueTask ReconcileAsync(CancellationToken ct) + { + if (m_registryNode is null) + { + return; + } + await m_gate.WaitAsync(ct).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_registry.Current; + + if (m_registryNode is RegistryState registryTyped && registryTyped.Labels is not null) + { + await SyncLabelPropertiesAsync( + registryTyped.Labels, RegistryNodeIdPath, snapshot.Labels, ct) + .ConfigureAwait(false); + } + + var seenGroups = new HashSet(StringComparer.Ordinal); + foreach (WotResourceGroup group in snapshot.Groups.Values) + { + seenGroups.Add(group.GroupId); + if (!m_groups.TryGetValue(group.GroupId, out GroupEntry? entry)) + { + entry = await CreateGroupNodeAsync(group, ct).ConfigureAwait(false); + m_groups[group.GroupId] = entry; + } + else + { + ApplyGroupProperties(entry.Node, group); + if (entry.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + entry.Node.Labels, GroupNodeIdPath(group.GroupId), group.Labels, ct) + .ConfigureAwait(false); + } + entry.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + + var seenResources = new HashSet(StringComparer.Ordinal); + foreach (WotResource resource in group.Resources.Values) + { + seenResources.Add(resource.ResourceId); + if (!entry.Resources.TryGetValue(resource.ResourceId, out ResourceEntry? res)) + { + res = await CreateResourceNodeAsync(entry, resource, ct) + .ConfigureAwait(false); + entry.Resources[resource.ResourceId] = res; + } + else + { + ApplyResourceProperties(res, resource); + if (res.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + res.Node.Labels, + ResourceNodeIdPath(resource.GroupId, resource.ResourceId), + resource.Labels, + ct).ConfigureAwait(false); + } + res.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + } + + foreach (string resourceId in entry.Resources.Keys + .Where(id => !seenResources.Contains(id)).ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + } + + foreach (string groupId in m_groups.Keys + .Where(id => !seenGroups.Contains(id)).ToList()) + { + await RemoveGroupNodeAsync(groupId, ct).ConfigureAwait(false); + } + } + finally + { + m_gate.Release(); + } + } + + public void Dispose() + { + if (m_disposed) + { + return; + } + m_disposed = true; + foreach (GroupEntry group in m_groups.Values) + { + foreach (ResourceEntry resource in group.Resources.Values) + { + resource.File?.Dispose(); + } + } + m_groups.Clear(); + m_resourcesByXid.Clear(); + m_gate.Dispose(); + } + + private async ValueTask CreateGroupNodeAsync( + WotResourceGroup group, CancellationToken ct) + { + bool tm = group.Kind == WoTDocumentKindEnum.ThingModel; + GroupState node = tm + ? new ThingModelGroupState(m_registryNode) + : new ThingDescriptionGroupState(m_registryNode); + NodeId nodeId = GroupNodeId(group.GroupId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelGroupType : ObjectTypeIds.ThingDescriptionGroupType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(group.GroupId, m_modelNs), new LocalizedText(group.Name), + assignNodeIds: false); + + node.AddCreateResource(m_manager.SystemContext) + .AddGetOrCreateResource(m_manager.SystemContext) + .AddDelete(m_manager.SystemContext) + .AddXid(m_manager.SystemContext) + .AddEpoch(m_manager.SystemContext) + .AddName(m_manager.SystemContext) + .AddDescription(m_manager.SystemContext) + .AddCreatedAt(m_manager.SystemContext) + .AddModifiedAt(m_manager.SystemContext) + .AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + string groupId = group.GroupId; + WoTDocumentKindEnum kind = group.Kind; + node.CreateResource?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnCreateResourceAsync(groupId, kind, c, i, ot, t); + node.GetOrCreateResource?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnGetOrCreateResourceAsync(groupId, kind, c, i, ot, t); + node.Delete?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteGroupAsync(groupId, c, i, t); + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddGroupLabelAsync(groupId, c, i, t), + (c, i, t) => OnRemoveGroupLabelAsync(groupId, c, i, t)); + + ApplyGroupProperties(node, group); + DateTime createdAt = DateTime.UtcNow; + SetValue(node.CreatedAt, (DateTimeUtc)createdAt); + SetValue(node.ModifiedAt, (DateTimeUtc)createdAt); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + m_registryNode!.AddChild(node); + m_registryNode.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, m_registryNode.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + var entry = new GroupEntry(node, group.Kind); + await SyncLabelPropertiesAsync( + node.Labels!, GroupNodeIdPath(group.GroupId), group.Labels, ct).ConfigureAwait(false); + return entry; + } + + private void ApplyGroupProperties(GroupState node, WotResourceGroup group) + { + SetValue(node.GroupId, group.GroupId); + SetValue(node.Xid, group.Xid); + SetValue(node.Epoch, (uint)group.Epoch); + SetValue(node.Name, group.Name); + SetValue(node.Description, group.Description); + } + + private async ValueTask RemoveGroupNodeAsync(string groupId, CancellationToken ct) + { + if (!m_groups.TryGetValue(groupId, out GroupEntry? entry)) + { + return; + } + foreach (string resourceId in entry.Resources.Keys.ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + m_registryNode!.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + m_registryNode.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + m_groups.Remove(groupId); + } + + private async ValueTask CreateResourceNodeAsync( + GroupEntry group, WotResource resource, CancellationToken ct) + { + bool tm = resource.Kind == WoTDocumentKindEnum.ThingModel; + WoTDocumentState node = tm + ? new ThingModelFileState(group.Node) + : new ThingDescriptionFileState(group.Node); + NodeId nodeId = ResourceNodeId(resource.GroupId, resource.ResourceId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelFileType : ObjectTypeIds.ThingDescriptionFileType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(resource.ResourceId, m_modelNs), + new LocalizedText(resource.Name), assignNodeIds: false); + + // Optional xRegistry registry metadata children. + node.AddVersionId(m_manager.SystemContext) + .AddFormat(m_manager.SystemContext) + .AddContentType(m_manager.SystemContext) + .AddXid(m_manager.SystemContext) + .AddEpoch(m_manager.SystemContext) + .AddName(m_manager.SystemContext) + .AddDescription(m_manager.SystemContext) + .AddCreatedAt(m_manager.SystemContext) + .AddModifiedAt(m_manager.SystemContext); + node.AddDesiredVersionId(m_manager.SystemContext) + .AddActiveVersionId(m_manager.SystemContext) + .AddIsDefault(m_manager.SystemContext) + .AddContentDigest(m_manager.SystemContext) + .AddValidationOutcome(m_manager.SystemContext) + .AddMaterializedNodeCount(m_manager.SystemContext) + .AddRootNodeId(m_manager.SystemContext) + .AddRefreshGeneration(m_manager.SystemContext) + .AddLastRefreshTime(m_manager.SystemContext); + node.AddDelete(m_manager.SystemContext); + node.AddValidate(m_manager.SystemContext); + node.AddSetEnabled(m_manager.SystemContext); + node.AddSetDefaultVersion(m_manager.SystemContext); + node.AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + if (node is ThingDescriptionFileState td) + { + td.AddThingId(m_manager.SystemContext) + .AddThingTitle(m_manager.SystemContext) + .AddBaseUri(m_manager.SystemContext); + } + else if (node is ThingModelFileState tmNode) + { + tmNode.AddModelTitle(m_manager.SystemContext) + .AddModelVersion(m_manager.SystemContext) + .AddDerivedTypeNodeId(m_manager.SystemContext); + } + + string groupId = resource.GroupId; + string resourceId = resource.ResourceId; + WoTDocumentKindEnum kind = resource.Kind; + node.Delete?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteResourceAsync(groupId, resourceId, c, i, t); + node.Validate?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnValidateAsync(groupId, resourceId, c, ot, t); + node.SetEnabled?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetEnabledAsync(groupId, resourceId, c, i, t); + node.SetDefaultVersion?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetDefaultVersionAsync(groupId, resourceId, c, i, t); + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddResourceLabelAsync(groupId, resourceId, c, i, t), + (c, i, t) => OnRemoveResourceLabelAsync(groupId, resourceId, c, i, t)); + + // FileType transfer for the document body (commit-on-close). + var file = new WotResourceFileManager( + node, + m_options.Bounds.MaxOpenFileHandles, + m_options.Bounds.MaxDocumentBytes, + m_manager.CheckManagementAccess, + (bytes, session, token) => CommitDocumentAsync(groupId, resourceId, kind, bytes, token)); + + ApplyResourceProperties(new ResourceEntry(node, file, groupId, resourceId, kind), resource); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + group.Node.AddChild(node); + group.Node.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, group.Node.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + m_resourcesByXid[BuildXid(resource.GroupId, resource.ResourceId)] = node; + var entry = new ResourceEntry(node, file, groupId, resourceId, kind); + await SyncLabelPropertiesAsync( + node.Labels!, ResourceNodeIdPath(groupId, resourceId), resource.Labels, ct) + .ConfigureAwait(false); + return entry; + } + + private void ApplyResourceProperties(ResourceEntry entry, WotResource resource) + { + WoTDocumentState node = entry.Node; + WotResourceVersion? version = resource.DefaultVersion; + WotResourceVersion? active = resource.ActiveVersion ?? version; + + SetValue(node.ResourceId, resource.ResourceId); + SetValue(node.VersionId, version?.VersionId ?? string.Empty); + SetValue(node.Format, version?.Format ?? string.Empty); + SetValue(node.ContentType, version?.ContentType ?? "application/td+json"); + SetValue(node.Xid, resource.Xid); + SetValue(node.Epoch, (uint)resource.Epoch); + SetValue(node.Name, resource.Name); + SetValue(node.Description, resource.Description); + if (version is not null) + { + SetValue(node.CreatedAt, (DateTimeUtc)version.CreatedAt); + } + SetValue(node.ModifiedAt, (DateTimeUtc)(version?.ModifiedAt ?? DateTime.UtcNow)); + + SetValue(node.DocumentKind, resource.Kind); + SetValue(node.Enabled, resource.Enabled); + SetValue(node.LoadState, resource.LoadState); + SetValue(node.DesiredVersionId, resource.DesiredVersionId ?? string.Empty); + SetValue(node.ActiveVersionId, resource.ActiveVersionId ?? string.Empty); + SetValue(node.IsDefault, version is not null && + string.Equals(version.VersionId, resource.DefaultVersionId, StringComparison.Ordinal)); + SetValue(node.ContentDigest, (ByteString)(version?.Digest ?? [])); + if (resource.Validation is not null) + { + SetValue(node.ValidationOutcome, resource.Validation); + } + SetValue(node.MaterializedNodeCount, (uint)resource.MaterializedNodeCount); + SetValue(node.RootNodeId, resource.RootNodeId); + SetValue(node.RefreshGeneration, resource.RefreshGeneration); + SetValue(node.LastRefreshTime, (DateTimeUtc)resource.LastRefreshTime); + + if (node is ThingDescriptionFileState td) + { + SetValue(td.ThingId, resource.ThingId ?? string.Empty); + SetValue(td.ThingTitle, resource.Title ?? string.Empty); + } + else if (node is ThingModelFileState tmNode) + { + SetValue(tmNode.ModelTitle, resource.Title ?? string.Empty); + SetValue(tmNode.DerivedTypeNodeId, resource.RootNodeId); + } + + byte[] content = active?.Content.ToArray() ?? []; + entry.File?.UpdatePersistedContent(content, version?.ContentType); + } + + private async ValueTask RemoveResourceNodeAsync( + GroupEntry group, string resourceId, CancellationToken ct) + { + if (!group.Resources.TryGetValue(resourceId, out ResourceEntry? entry)) + { + return; + } + entry.File?.Dispose(); + m_resourcesByXid.TryRemove(BuildXid(entry.GroupId, entry.ResourceId), out _); + group.Node.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + group.Node.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + group.Resources.Remove(resourceId); + } + + private async ValueTask OnCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResourceGroup? group = await m_registry + .TryCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + if (group is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, $"Group '{groupId}' already exists."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + return ServiceResult.Good; + } + + private async ValueTask OnGetOrCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + bool existed = m_registry.Current.FindGroup(NormalizeId(groupId!)) is not null; + WotResourceGroup group = await m_registry + .GetOrCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + output.Add(new Variant(!existed)); + return ServiceResult.Good; + } + + private async ValueTask OnCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResource? resource = await m_registry + .TryCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + if (resource is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, + $"Resource '{resourceId}' already exists in group '{groupId}'."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + return await CompleteResourceOutputAsync( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created: null, ct) + .ConfigureAwait(false); + } + + private async ValueTask OnGetOrCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + (WotResource resource, bool created) = await m_registry + .GetOrCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return await CompleteResourceOutputAsync( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created, ct) + .ConfigureAwait(false); + } + + private async ValueTask CompleteResourceOutputAsync( + string groupId, string resourceId, bool requestOpen, + ISystemContext context, List output, bool? created, CancellationToken ct) + { + NodeId nodeId = ResourceNodeId(groupId, resourceId); + uint fileHandle = 0; + await m_gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (requestOpen && + m_groups.TryGetValue(groupId, out GroupEntry? group) && + group.Resources.TryGetValue(resourceId, out ResourceEntry? entry) && + entry.File is not null) + { + ServiceResult open = entry.File.TryOpenWriteHandle( + context is ISessionSystemContext sessionContext + ? sessionContext.SessionId.GetValueOrDefault() + : NodeId.Null, + out fileHandle); + if (ServiceResult.IsBad(open)) + { + return open; + } + } + } + finally + { + m_gate.Release(); + } + + WotResource? resource = m_registry.Current.FindResource(groupId, resourceId); + output.Clear(); + output.Add(new Variant(nodeId)); + output.Add(new Variant(resource?.DefaultVersionId ?? string.Empty)); + output.Add(new Variant(fileHandle)); + if (created is { } wasCreated) + { + output.Add(new Variant(wasCreated)); + } + return ServiceResult.Good; + } + + private async ValueTask OnDeleteGroupAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteGroupAsync(groupId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnDeleteResourceAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteResourceAsync(groupId, resourceId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnValidateAsync( + string groupId, string resourceId, ISystemContext context, + List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "Validate"); + if (ServiceResult.IsBad(access)) + { + return access; + } + WoTValidationOutcomeDataType outcome; + try + { + outcome = await m_registry.ValidateResourceAsync(groupId, resourceId, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); +#pragma warning disable CS0618 // Validate generated proxy expects a direct structure Variant. + output.Add(new Variant(outcome)); +#pragma warning restore CS0618 + return ServiceResult.Good; + } + + private async ValueTask OnSetEnabledAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetEnabled"); + if (ServiceResult.IsBad(access)) + { + return access; + } + if (GetBoolOrNull(input, 0) is not { } enabled) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The Enabled argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetEnabledAsync(groupId, resourceId, enabled, epoch, ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnSetDefaultVersionAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetDefaultVersion"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? versionId = GetString(input, 0); + if (string.IsNullOrEmpty(versionId)) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The VersionId argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetDefaultVersionAsync(groupId, resourceId, versionId!, epoch, ct) + .ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask CommitDocumentAsync( + string groupId, string resourceId, WoTDocumentKindEnum kind, + byte[] content, CancellationToken ct) + { + var request = new WotUpsertResourceRequest + { + GroupId = groupId, + ResourceId = resourceId, + Kind = kind, + Content = content, + ContentType = kind == WoTDocumentKindEnum.ThingModel + ? "application/tm+json" + : "application/td+json", + Format = kind == WoTDocumentKindEnum.ThingModel ? "WoT-TM/1.1" : "WoT-TD/1.1", + SetAsDefault = true + }; + WotRegistryMutationResult result = await m_registry + .UpsertResourceAsync(request, ct).ConfigureAwait(false); + // A validation failure still stores the version (Warning): the bytes + // are never lost and the previous active projection is retained. + return result.Outcome is WoTOutcomeEnum.Rejected or WoTOutcomeEnum.Failed + ? ServiceResult.Create(StatusCodes.BadInvalidState, result.Message) + : ServiceResult.Good; + } + + /// + /// Wires the AddAttribute/RemoveAttribute Method handlers on a + /// materialized Labels (AttributesType) container, instantiating the + /// two optional Method children when not already present. + /// + private void WireLabelsContainer( + AttributesState? labels, + Func, CancellationToken, ValueTask> onAdd, + Func, CancellationToken, ValueTask> onRemove) + { + if (labels is null) + { + return; + } + labels.AddAddAttribute(m_manager.SystemContext) + .AddRemoveAttribute(m_manager.SystemContext); + labels.AddAttribute?.OnCallMethod2Async = (c, m, o, i, ot, t) => onAdd(c, i, t); + labels.RemoveAttribute?.OnCallMethod2Async = (c, m, o, i, ot, t) => onRemove(c, i, t); + } + + /// + /// Reconciles the browsable label Property children of a Labels + /// container against the desired dictionary: adds/updates changed + /// values, and removes labels no longer present. Ordinal enumeration + /// of keeps materialization order + /// deterministic. + /// + private async ValueTask SyncLabelPropertiesAsync( + AttributesState labels, + string basePath, + ImmutableSortedDictionary desired, + CancellationToken ct) + { + ISystemContext context = m_manager.SystemContext; + var existing = new Dictionary>(StringComparer.Ordinal); + var children = new List(); + labels.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is PropertyState property && property.BrowseName.Name is string name) + { + existing[name] = property; + } + } + + foreach (KeyValuePair label in desired) + { + if (existing.TryGetValue(label.Key, out PropertyState? property)) + { + if (!string.Equals(property.Value, label.Value, StringComparison.Ordinal)) + { + property.Value = label.Value; + property.ClearChangeMasks(context, includeChildren: false); + } + continue; + } + PropertyState created = labels.AddAttribute_Placeholder( + context, new QualifiedName(label.Key, m_modelNs)); + created.NodeId = LabelNodeId(basePath, label.Key); + created.Value = label.Value; + await m_manager.AddPredefinedNodeAsync(created, ct).ConfigureAwait(false); + } + + foreach (KeyValuePair> stale in existing + .Where(kv => !desired.ContainsKey(kv.Key)).ToList()) + { + labels.RemoveChild(stale.Value); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, stale.Value.NodeId, ct) + .ConfigureAwait(false); + } + } + + private NodeId LabelNodeId(string basePath, string key) + { + return new NodeId($"{basePath}/labels/{key}", m_modelNs); + } + + private async ValueTask OnAddRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddRegistryLabelAsync(key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveRegistryLabelAsync(key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddGroupLabelAsync(groupId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveGroupLabelAsync(groupId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddResourceLabelAsync(groupId, resourceId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveResourceLabelAsync(groupId, resourceId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private WoTDocumentKindEnum KindForGroup(string groupId) + { + return string.Equals(NormalizeId(groupId), WotRegistryGroups.ThingModels, StringComparison.Ordinal) + ? WoTDocumentKindEnum.ThingModel + : WoTDocumentKindEnum.ThingDescription; + } + + private static string NormalizeId(string id) + { + return WotRegistryService.NormalizeSegment(id, nameof(id)); + } + + private static string BuildXid(string groupId, string resourceId) + { + return $"/groups/{groupId}/resources/{resourceId}"; + } + + private const string RegistryNodeIdPath = "WoTRegistry"; + + private static string GroupNodeIdPath(string groupId) + { + return "WoTRegistry/groups/" + groupId; + } + + private static string ResourceNodeIdPath(string groupId, string resourceId) + { + return $"WoTRegistry/groups/{groupId}/resources/{resourceId}"; + } + + private NodeId GroupNodeId(string groupId) + { + return new NodeId(GroupNodeIdPath(groupId), m_modelNs); + } + + private NodeId ResourceNodeId(string groupId, string resourceId) + { + return new NodeId(ResourceNodeIdPath(groupId, resourceId), m_modelNs); + } + + private void WireMethod( + BaseObjectState parent, string browseName, GenericMethodCalledEventHandler2Async handler) + { + MethodState? method = + parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, XRegistryNs)) as + MethodState + ?? parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, m_modelNs)) as + MethodState; + method?.OnCallMethod2Async = handler; + } + + private ushort XRegistryNs + => (ushort)m_manager.Server.NamespaceUris.GetIndex(XRegistryWellKnown.XRegistryNamespaceUri); + + /// + /// Links the / + /// Properties of every Method in + /// the subtree from their materialized child nodes. The generated + /// instance factories add the argument nodes as plain children without + /// setting these Properties, which the server's Call argument validation + /// requires. + /// + internal static void LinkMethodArguments(NodeState? node, ISystemContext context) + { + if (node is null) + { + return; + } + if (node is MethodState method) + { + var arguments = new List(); + method.GetChildren(context, arguments); + foreach (BaseInstanceState child in arguments) + { + if (child is not PropertyState> args) + { + continue; + } + if (method.InputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.InputArguments, + StringComparison.Ordinal)) + { + method.InputArguments = args; + } + else if (method.OutputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.OutputArguments, + StringComparison.Ordinal)) + { + method.OutputArguments = args; + } + } + } + var children = new List(); + node.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + LinkMethodArguments(child, context); + } + } + + private static void SetValue(PropertyState? property, T value) + { + property?.Value = value; + } + + private static string? GetString(ArrayOf input, int index) + { + return index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s + ? s : null; + } + + private static bool GetBool(ArrayOf input, int index, bool fallback) + { + return GetBoolOrNull(input, index) ?? fallback; + } + + private static bool? GetBoolOrNull(ArrayOf input, int index) + { + return index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is bool b + ? b : null; + } + + private static long? OptionalEpoch(ArrayOf input, int index) + { + if (index >= input.Count) + { + return null; + } + return input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) switch + { + uint u => u == 0 ? null : u, + int i => i == 0 ? null : i, + long l => l == 0 ? null : l, + _ => null + }; + } + + private static ServiceResult ToServiceResult(WotRegistryMutationResult result) + { + return result.Outcome switch + { + WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning or WoTOutcomeEnum.Unchanged + => ServiceResult.Good, + WoTOutcomeEnum.Rejected + => ServiceResult.Create(StatusCodes.BadInvalidState, result.Message), + _ => ServiceResult.Create(StatusCodes.BadNodeIdUnknown, result.Message) + }; + } + + private sealed class GroupEntry + { + public GroupEntry(GroupState node, WoTDocumentKindEnum kind) + { + Node = node; + Kind = kind; + } + + public GroupState Node { get; } + public WoTDocumentKindEnum Kind { get; } + + public Dictionary Resources { get; } + = new(StringComparer.Ordinal); + } + + private sealed class ResourceEntry + { + public ResourceEntry( + WoTDocumentState node, WotResourceFileManager? file, + string groupId, string resourceId, WoTDocumentKindEnum kind) + { + Node = node; + File = file; + GroupId = groupId; + ResourceId = resourceId; + Kind = kind; + } + + public WoTDocumentState Node { get; } + public WotResourceFileManager? File { get; } + public string GroupId { get; } + public string ResourceId { get; } + public WoTDocumentKindEnum Kind { get; } + } + + private readonly WotRegistryNodeManager m_manager; + private readonly IWotRegistryService m_registry; + private readonly WotRegistryServerOptions m_options; + private readonly ushort m_modelNs; + private readonly SemaphoreSlim m_gate = new(1, 1); + private readonly Dictionary m_groups = new(StringComparer.Ordinal); + + private readonly System.Collections.Concurrent.ConcurrentDictionary + m_resourcesByXid = new(StringComparer.Ordinal); + + private BaseObjectState? m_registryNode; + private bool m_disposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs new file mode 100644 index 0000000000..b775a3d955 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * 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.Collections.Generic; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry.Server; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Options for the WoT Connectivity 1.1 registry NodeManager. These are + /// bindable from configuration (only the simple-typed members) and augmented + /// at runtime with the persistence store, binder registry and management + /// access policy. + /// + public sealed class WotRegistryServerOptions + { + /// + /// Gets or sets the folder used by the file-backed registry store. When + /// null the registry is kept in memory only. + /// + public string? StorageFolder { get; set; } + + /// + /// Gets or sets the store that holds the document bytes. When null the documents + /// live wherever the registry store puts them — in the server process for an in-memory + /// registry, or in the registry folder for a file-backed one. + /// + /// Substituting a store is what lets a registry run in a high-availability or distributed + /// deployment, because the documents then live somewhere every node can reach. The store is + /// keyed by the SHA-256 content digest of the document, so writes are idempotent and two + /// resource versions with identical bytes share one entry. A supplied store owns the + /// durability of the bytes it holds; the registry still writes and switches its own + /// manifest atomically, which is safe because content-addressed documents are immutable and + /// are always written before the manifest that references them. + /// + /// + public IXRegistryResourceStore? ResourceStore { get; set; } + + /// + /// Gets or sets whether the registry automatically re-projects after every + /// content mutation. Defaults to true. + /// + public bool AutoRefresh { get; set; } = true; + + /// + /// Gets or sets whether unsupported binding forms fail a strict closure + /// (rather than materializing degraded nodes). + /// + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how a superseded projection generation is retired after + /// a successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets or sets the id of the group into which legacy 1.02 assets are + /// registered as Thing Description resources. + /// + public string LegacyGroupId { get; set; } = WotRegistryGroups.ThingDescriptions; + + /// + /// Gets the resource bounds enforced by the registry service. + /// + public WotRegistryPersistenceBounds Bounds { get; } = new WotRegistryPersistenceBounds(); + + /// + /// Gets or sets the management access policy used to secure the registry + /// management Methods. + /// + public WotManagementAccessPolicy ManagementAccess { get; set; } + = new WotManagementAccessPolicy(); + + /// + /// Gets the WoT binding capabilities advertised by the registry + /// SupportedBindings object. Empty in this phase (no concrete + /// protocol binders are registered). + /// + public IList SupportedBindings { get; } + = []; + } +} From 689e19495fec63690b22d060377f826fb39e2d0c Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 08:41:26 +0200 Subject: [PATCH 2/7] Add WoT registry materialization tests Bring the PR14 materialization, dependency graph, binding runtime, registry projection lifecycle, and NodeManager coverage across from the stacked integration branch. Keep the tests compiling for both net10.0 and net48 by naming ImmutableDictionary.Empty on the binding plan models and updating the memory binding test seam to the current codec resolver contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../IWotBindingPlanner.cs | 2 +- .../IWotCredentialProvider.cs | 6 +- src/Opc.Ua.WotCon.Bindings/WotBindingPlan.cs | 2 +- .../WotBindingPlanModel.cs | 8 +- ...tryServerBuilderExtensionsCoverageTests.cs | 331 +++++++ ...WotRegistryServerBuilderExtensionsTests.cs | 102 ++ .../LifecycleWotProjectionHostBindingTests.cs | 122 +++ .../WotBindingChannelSlotTests.cs | 138 +++ .../WotBindingCoordinatorTests.cs | 490 ++++++++++ .../WotDependencyGraphExtendedTests.cs | 436 +++++++++ .../WotDependencyGraphTests.cs | 147 +++ .../WotDocumentConverterTests.cs | 185 ++++ ...MaterializationCoordinatorExtendedTests.cs | 374 ++++++++ .../WotMaterializationCoordinatorTests.cs | 441 +++++++++ .../WotMaterializationExtensibilityTests.cs | 270 ++++++ .../WotMaterializationTestSupport.cs | 246 +++++ .../WotProjectionBindingRuntimeTestSupport.cs | 554 +++++++++++ .../WotProjectionBindingRuntimeTests.cs | 866 +++++++++++++++++ .../WotRefreshArgumentsExtendedTests.cs | 211 +++++ .../WotRefreshArgumentsTests.cs | 315 ++++++ .../WotStructuredGroupStateTests.cs | 166 ++++ .../WotTargetVariableResolverTests.cs | 301 ++++++ .../Opc.Ua.WotCon.Tests.csproj | 4 + .../Registry/WotBlobResourceStoreTests.cs | 3 + .../WotRegistryEventIntegrationTests.cs | 501 ++++++++++ .../WotRegistryLifecycleTests.cs | 895 ++++++++++++++++++ .../Support/MemoryWotBinding.cs | 253 +++++ 27 files changed, 7360 insertions(+), 9 deletions(-) create mode 100644 tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs create mode 100644 tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs diff --git a/src/Opc.Ua.WotCon.Bindings/IWotBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/IWotBindingPlanner.cs index 41bf99397b..f19ff26e16 100644 --- a/src/Opc.Ua.WotCon.Bindings/IWotBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/IWotBindingPlanner.cs @@ -51,7 +51,7 @@ public WotBindingPlanContext( string? baseUri = null, WotBindingBounds? bounds = null) { - SecurityDefinitions = securityDefinitions ?? []; + SecurityDefinitions = securityDefinitions ?? ImmutableDictionary.Empty; Codecs = codecs ?? WotPayloadCodecRegistry.Default; DocumentKind = documentKind; BaseUri = baseUri; diff --git a/src/Opc.Ua.WotCon.Bindings/IWotCredentialProvider.cs b/src/Opc.Ua.WotCon.Bindings/IWotCredentialProvider.cs index 1cff3a18df..c9ae435def 100644 --- a/src/Opc.Ua.WotCon.Bindings/IWotCredentialProvider.cs +++ b/src/Opc.Ua.WotCon.Bindings/IWotCredentialProvider.cs @@ -279,9 +279,9 @@ public WotCredential( IReadOnlyList? trustedCertificates = null) { Scheme = scheme; - Headers = headers ?? []; - QueryParameters = queryParameters ?? []; - Properties = properties ?? []; + Headers = headers ?? ImmutableDictionary.Empty; + QueryParameters = queryParameters ?? ImmutableDictionary.Empty; + Properties = properties ?? ImmutableDictionary.Empty; ClientCertificate = clientCertificate; TrustedCertificates = trustedCertificates is null ? [] diff --git a/src/Opc.Ua.WotCon.Bindings/WotBindingPlan.cs b/src/Opc.Ua.WotCon.Bindings/WotBindingPlan.cs index abb3952e7c..2f7f101013 100644 --- a/src/Opc.Ua.WotCon.Bindings/WotBindingPlan.cs +++ b/src/Opc.Ua.WotCon.Bindings/WotBindingPlan.cs @@ -56,7 +56,7 @@ public WotBindingPlanRequest( ResourceXid = resourceXid ?? string.Empty; Kind = kind; Forms = forms.IsDefault ? [] : forms; - SecurityDefinitions = securityDefinitions ?? []; + SecurityDefinitions = securityDefinitions ?? ImmutableDictionary.Empty; BaseUri = baseUri; Selection = selection ?? WotBindingSelectionContext.Empty; } diff --git a/src/Opc.Ua.WotCon.Bindings/WotBindingPlanModel.cs b/src/Opc.Ua.WotCon.Bindings/WotBindingPlanModel.cs index 0d34d93a44..7400b2cd22 100644 --- a/src/Opc.Ua.WotCon.Bindings/WotBindingPlanModel.cs +++ b/src/Opc.Ua.WotCon.Bindings/WotBindingPlanModel.cs @@ -113,7 +113,7 @@ public WotEndpointDescriptor( Host = host; Port = port; BaseUri = baseUri ?? string.Empty; - Metadata = metadata ?? []; + Metadata = metadata ?? ImmutableDictionary.Empty; } /// @@ -153,7 +153,7 @@ public sealed class WotAddressingDescriptor public WotAddressingDescriptor(string target, ImmutableDictionary? metadata = null) { Target = target ?? string.Empty; - Metadata = metadata ?? []; + Metadata = metadata ?? ImmutableDictionary.Empty; } /// @@ -186,7 +186,7 @@ public WotOperationDescriptor( Operation = operation; OpToken = opToken ?? string.Empty; Method = method ?? string.Empty; - Metadata = metadata ?? []; + Metadata = metadata ?? ImmutableDictionary.Empty; PollInterval = pollInterval; } @@ -235,7 +235,7 @@ public WotPayloadDescriptor( { ContentType = contentType ?? string.Empty; CodecId = codecId ?? string.Empty; - Metadata = metadata ?? []; + Metadata = metadata ?? ImmutableDictionary.Empty; } /// diff --git a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs new file mode 100644 index 0000000000..c77e5ce943 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs @@ -0,0 +1,331 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Opc.Ua.Server.Hosting; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Hosting +{ + /// + /// Exercises every overload of + /// IOpcUaBuilder.AddWotRegistryServer(...) defined in + /// OpcUaWotRegistryServerBuilderExtensions: null-guard validation, + /// options binding, and the set of singleton services that must be + /// resolvable from the built without a + /// running OPC UA server. + /// + [TestFixture] + [Category("WotCon")] + [Category("Builder")] + [Parallelizable] + public sealed class OpcUaWotRegistryServerBuilderExtensionsCoverageTests + { + [Test] + public void AddWotRegistryServerWithNoConfigureRegistersServices() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.That(sp.GetRequiredService(), Is.Not.Null); + Assert.That(sp.GetRequiredService(), Is.Not.Null); + } + + [Test] + public void AddWotRegistryServerWithConfigureActionAppliesOptions() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(o => o.StorageFolder = "custom-folder"); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("custom-folder")); + } + + [Test] + public void AddWotRegistryServerWithNullConfigureActionDoesNotThrow() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer(configure: null), + Throws.Nothing); + } + + [Test] + public void AddWotRegistryServerWithNullBuilderThrowsArgumentNull() + { + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, configure: null), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithConfigurationBindsOptions() + { + var configData = new Dictionary + { + [$"{OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection}:StorageFolder"] = + "my-registry-data" + }; + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(configuration); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("my-registry-data")); + } + + [Test] + public void AddWotRegistryServerWithNullConfigurationThrowsArgumentNull() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer((IConfiguration)null!), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithConfigurationSectionBindsOptions() + { + var configData = new Dictionary + { + [$"{OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection}:StorageFolder"] = + "section-folder" + }; + + IConfigurationSection section = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build() + .GetSection(OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection); + + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(section); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("section-folder")); + } + + [Test] + public void AddWotRegistryServerWithNullSectionThrowsArgumentNull() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer((IConfigurationSection)null!), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithNullBuilderAndSectionThrowsArgumentNull() + { + IConfigurationSection section = new ConfigurationBuilder() + .Build() + .GetSection("OpcUa"); + + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, section), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerReturnsTheSameBuilder() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + IOpcUaBuilder returned = builder.AddWotRegistryServer(); + + Assert.That(returned, Is.SameAs(builder)); + } + + [Test] + public void AddWotRegistryServerRegistersIWotRegistryServiceAsSingleton() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService first = sp.GetRequiredService(); + IWotRegistryService second = sp.GetRequiredService(); + + Assert.That(first, Is.SameAs(second), + "IWotRegistryService must be registered as a singleton."); + } + + [Test] + public void AddWotRegistryServerRegistersWotMaterializationCoordinator() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(WotMaterializationCoordinator)), + Is.True, + "WotMaterializationCoordinator must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersWotRegistryNodeManagerFactory() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(WotRegistryNodeManagerFactory)), + Is.True, + "WotRegistryNodeManagerFactory must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersOpcUaServerNodeManagerRegistration() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(OpcUaServerNodeManagerRegistration)), + Is.True, + "OpcUaServerNodeManagerRegistration must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersBinderRegistryInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = + sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = + sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void AddWotRegistryServerRegistersTargetVariableResolver() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.That( + sp.GetRequiredService(), + Is.Not.Null); + } + + [Test] + public void AddWotRegistryServerCalledTwiceDoesNotDoubleRegisterSingleton() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(o => o.StorageFolder = "first"); + builder.AddWotRegistryServer(o => o.StorageFolder = "second"); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService first = sp.GetRequiredService(); + IWotRegistryService second = sp.GetRequiredService(); + + Assert.That(first, Is.SameAs(second), + "Calling AddWotRegistryServer twice must still yield a single singleton."); + } + + [Test] + public void AddWotRegistryServerUsesInMemoryStoreWhenNoFolderConfigured() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(o => o.StorageFolder = null); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService svc = sp.GetRequiredService(); + Assert.That(svc, Is.InstanceOf()); + } + + [Test] + public void AddWotRegistryServerDefaultConfigSectionIsConstant() + { + Assert.That( + OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection, + Is.EqualTo("OpcUa:WotConRegistry:Server")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs new file mode 100644 index 0000000000..d4e88b01e4 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs @@ -0,0 +1,102 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Hosting +{ + /// + /// Verifies that AddWotRegistryServer and + /// AddWotProtocolBinders/AddWotBinder/AddWotBindingExecutor + /// register the aggregating exactly + /// once and expose the same singleton instance as both + /// and , + /// regardless of registration order. + /// + [TestFixture] + public sealed class OpcUaWotRegistryServerBuilderExtensionsTests + { + [Test] + public void RegistryThenBindersExposesTheSameSingletonForBothInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + builder.AddWotProtocolBinders(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void BindersThenRegistryExposesTheSameSingletonForBothInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotProtocolBinders(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void RegistryOnlyStillExposesAWorkingRegistryAndChannelFactory() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + Assert.That(registry.Capabilities, Is.Empty, + "With no binders registered, the shared registry advertises no capabilities."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs new file mode 100644 index 0000000000..784b4e7d8b --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs @@ -0,0 +1,122 @@ +/* ======================================================================== + * 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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Server.RuntimeNodeSet; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Verifies that installs + /// so each runtime + /// NodeSet generation owns its own binding runtime, built from the + /// injected and the + /// document's prepared binding plans. + /// + [TestFixture] + public sealed class LifecycleWotProjectionHostBindingTests + { + [Test] + public async Task ConfigureAsyncIsInstalledAndForwardsBindingPlansToTheRuntimeFactory() + { + var runtimeFactory = new RecordingProjectionBindingRuntimeFactory(); + var host = new LifecycleWotProjectionHost(Mock.Of(), runtimeFactory); + + var harness = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: harness.ScalarNodeIdText)); + ArrayOf plans = new[] + { + WotProjectionBindingRuntimeTestHarness.Plan(form) + }.ToArrayOf(); + var document = new WotProjectionDocument( + "closure-a", [], plans); + + RuntimeNodeSetOptions options = InvokeBuildOptions(host, document); + + Assert.That(options.ConfigureAsync, Is.Not.Null, + "The host must install ConfigureAsync when a runtime factory is supplied."); + + IAsyncDisposable? result = await options.ConfigureAsync!( + harness.Builder, CancellationToken.None).ConfigureAwait(false); + + Assert.That(runtimeFactory.LastBuilder, Is.SameAs(harness.Builder)); + Assert.That(runtimeFactory.LastBindingPlans.Count, Is.EqualTo(1)); + Assert.That(runtimeFactory.LastBindingPlans[0], Is.SameAs(plans[0])); + Assert.That(result, Is.Null); + } + + [Test] + public void ConfigureAsyncIsNotInstalledWhenNoRuntimeFactoryIsSupplied() + { + var host = new LifecycleWotProjectionHost(Mock.Of()); + var document = new WotProjectionDocument("closure-a", []); + + RuntimeNodeSetOptions options = InvokeBuildOptions(host, document); + + Assert.That(options.ConfigureAsync, Is.Null, + "Without a runtime factory the host must preserve the pre-existing (data-only) behavior."); + } + + private static RuntimeNodeSetOptions InvokeBuildOptions( + LifecycleWotProjectionHost host, WotProjectionDocument document) + { + MethodInfo method = typeof(LifecycleWotProjectionHost).GetMethod( + "BuildOptions", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (RuntimeNodeSetOptions)method.Invoke(host, [document])!; + } + + private sealed class RecordingProjectionBindingRuntimeFactory : IWotProjectionBindingRuntimeFactory + { + public INodeManagerBuilder? LastBuilder { get; private set; } + + public ArrayOf LastBindingPlans { get; private set; } + + public ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default) + { + LastBuilder = builder; + LastBindingPlans = bindingPlans; + return new ValueTask((IAsyncDisposable?)null); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs new file mode 100644 index 0000000000..04f326c3a7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs @@ -0,0 +1,138 @@ +/* ======================================================================== + * 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; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises directly: single-flight + /// open caching, failed-open eviction (already covered end-to-end through + /// ), and — the focus here — + /// hardening against GetAsync racing with, or being called after, + /// DisposeAsync, so a channel this slot opens can never escape + /// disposal. + /// + [TestFixture] + public sealed class WotBindingChannelSlotTests + { + [Test] + public async Task GetAsyncAfterDisposeThrowsObjectDisposedExceptionAndNeverOpensAChannel() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var slot = new WotBindingChannelSlot(form, factory); + + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.Zero, "A disposed slot must never open a channel."); + } + + [Test] + public async Task GetAsyncAfterDisposeOfAnOpenedSlotThrowsAndNeverOpensASecondChannel() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + factory.SetChannel(form, channel); + var slot = new WotBindingChannelSlot(form, factory); + + await slot.GetAsync(CancellationToken.None).ConfigureAwait(false); + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.EqualTo(1), "A disposed slot must never open a second channel."); + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsyncRacingAnInFlightGetAsyncDisposesTheOpenedChannelExactlyOnceNoLeak() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + var openGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + factory.SetOpener(form, async () => + { + await openGate.Task.ConfigureAwait(false); + return channel; + }); + var slot = new WotBindingChannelSlot(form, factory); + + // GetAsync starts the open (which will not complete until the + // gate is released) and DisposeAsync races it while the open is + // still in flight. + Task getTask = slot.GetAsync(CancellationToken.None).AsTask(); + Task disposeTask = slot.DisposeAsync().AsTask(); + + openGate.SetResult(true); + IWotBindingChannel got = await getTask.ConfigureAwait(false); + await disposeTask.ConfigureAwait(false); + + Assert.That(got, Is.SameAs(channel)); + Assert.That(factory.OpenCount, Is.EqualTo(1), "Single-flight must hold even when racing dispose."); + Assert.That(channel.DisposeCount, Is.EqualTo(1), + "The channel opened concurrently with dispose must still be disposed exactly once — no leak."); + + // Post-race: the slot is disposed, so no later GetAsync may open + // another channel. + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsyncIsIdempotentSecondCallDoesNotRedisposeOrThrow() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + factory.SetChannel(form, channel); + var slot = new WotBindingChannelSlot(form, factory); + await slot.GetAsync(CancellationToken.None).ConfigureAwait(false); + + await slot.DisposeAsync().ConfigureAwait(false); + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs new file mode 100644 index 0000000000..e64322a5d7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs @@ -0,0 +1,490 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Tests.Support; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator's binding lifecycle: strict vs + /// degraded closure selection, non-executable degradation, and the + /// activate-after-commit / deactivate-before-retire ordering. + /// + [TestFixture] + public sealed class WotBindingCoordinatorTests + { + private static byte[] Td(string id, string href, string extraTerms = "") + { + string terms = string.IsNullOrEmpty(extraTerms) ? string.Empty : "," + extraTerms; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"id\":\"" + + id + + "\",\"title\":\"t\"," + + "\"properties\":{\"value\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + href + + "\"" + + terms + + "}]}}}"; + return Encoding.UTF8.GetBytes(td); + } + + private static WotRegistryService Registry() + { + return new(); + } + + private static Task Upsert( + WotRegistryService registry, string resourceId, byte[] content) + { + return registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + [Test] + public async Task StrictUnsupportedFormFailsClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = true + }; + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.Zero, "A strict closure with unsupported forms must not project."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task DegradedUnsupportedFormMaterializesWithWarningAndBindingFailure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = false + }; + var events = new List(); + coordinator.Event += (_, e) => events.Add(e); + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.EqualTo(1), "A degraded closure still materializes nodes."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(events.Any(e => e.Kind == WotMaterializationEventKind.BindingFailure), Is.True, + "Degraded mode must emit a binding failure event."); + } + + [Test] + public async Task NonExecutableFormDegradesClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert( + registry, "td-a", Td("urn:td-a", "coap://d/temp", "\"cov:method\":\"GET\"")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.EqualTo(1)); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning), + "A validated but non-executable binding degrades the closure."); + } + + [Test] + public async Task ExecutableFormIsNotDegraded() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry( + [new MemoryWotBinder()], + [new MemoryWotBindingExecutor(new MemoryWotStore())]); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Success), + "A fully executable binding is not degraded."); + } + + [Test] + public async Task RefreshPassesPreparedBindingPlansIntoTheProjectionDocument() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry( + [new MemoryWotBinder()], + [new MemoryWotBindingExecutor(new MemoryWotStore())]); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + HostOperation operation = host.Operations.Single(o => o.Op == "add"); + Assert.That(operation.Document, Is.Not.Null); + Assert.That(operation.Document!.BindingPlans.Count, Is.EqualTo(1), + "The coordinator must pass exactly one prepared plan per projected member."); + Assert.That(operation.Document.BindingPlans[0].HasExecutableForms, Is.True, + "The plan passed to the host must be the executable plan the coordinator prepared."); + } + + [Test] + public async Task LifecycleActivatesAfterCommitDeactivatesBeforeRetire() + { + WotRegistryService registry = Registry(); + var timeline = new List(); + var host = new RecordingProjectionHost(timeline); + var binders = new RecordingBinderRegistry(timeline); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + await registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a").ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + int add = timeline.IndexOf("add"); + int activate = timeline.IndexOf("activate"); + int deactivate = timeline.IndexOf("deactivate"); + int remove = timeline.IndexOf("remove"); + + Assert.That(add, Is.GreaterThanOrEqualTo(0)); + Assert.That(activate, Is.GreaterThan(add), "Activate must follow the projection commit."); + Assert.That(deactivate, Is.GreaterThanOrEqualTo(0)); + Assert.That(remove, Is.GreaterThan(deactivate), "Deactivate must precede retirement."); + } + + [Test] + public async Task UpdateDeactivatesExactlyOldPlansInCorrectOrder() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + // A content change triggers a shadow reload (an update, not a first add). + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + WotBindingPlan planV2 = binders.ActivatedPlans[1]; + Assert.That(planV2, Is.Not.SameAs(planV1), "The update must prepare a new plan."); + + // Exactly one deactivation, and it is the old plan (never the new one). + Assert.That(binders.DeactivatedPlans, Has.Count.EqualTo(1)); + Assert.That(binders.DeactivatedPlans[0], Is.SameAs(planV1), + "Only the previously tracked plan may be deactivated on update."); + + // Order: the shadow switch happens first, then the old plan is + // deactivated, then the new plan is activated. + int shadow = recorder.IndexOf("shadow"); + int deactivateOld = recorder.IndexOf("deactivate", planV1); + int activateNew = recorder.IndexOf("activate", planV2); + Assert.That(shadow, Is.GreaterThanOrEqualTo(0), "The update must shadow-reload the projection."); + Assert.That(deactivateOld, Is.GreaterThan(shadow), + "The old plan must be deactivated only after the shadow switch succeeds."); + Assert.That(activateNew, Is.GreaterThan(deactivateOld), + "The new plan must be activated after the old plan is deactivated."); + } + + [Test] + public async Task UpdateShadowReloadFailsOldPlansRemainActive() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + + // The shadow switch fails: the old plans must remain active (no + // deactivation) and no new plan may be activated (rollback ordering). + host.FailShadowReload = true; + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(binders.DeactivatedPlans, Is.Empty, + "A failed shadow switch must not deactivate the still-active old plan."); + Assert.That(binders.ActivatedPlans, Has.Count.EqualTo(1), + "A failed shadow switch must not activate the new plan."); + Assert.That(binders.ActivatedPlans[0], Is.SameAs(planV1)); + } + + private sealed class PlanRecorder + { + public List<(string Action, WotBindingPlan? Plan)> Events { get; } = []; + + public void Record(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + Events.Add((action, plan)); + } + } + + public int IndexOf(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + return Events.FindIndex(e => + e.Action == action && (plan is null || ReferenceEquals(e.Plan, plan))); + } + } + } + + private sealed class PlanRecordingHost : IWotProjectionHost + { + public PlanRecordingHost(PlanRecorder recorder) + { + m_recorder = recorder; + } + + public bool FailShadowReload { get; set; } + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_recorder.Record("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (FailShadowReload) + { + throw new System.IO.IOException("Injected shadow reload failure."); + } + m_recorder.Record("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_recorder.Record("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_recorder.Record("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + { + return new(document.ClosureKey, 1, new FakeWotProjectionRegistration(), [], 0); + } + + private readonly PlanRecorder m_recorder; + } + + private sealed class PlanRecordingBinderRegistry : IWotBinderRegistry + { + public PlanRecordingBinderRegistry(PlanRecorder recorder) + { + m_recorder = recorder; + } + + public List ActivatedPlans { get; } = []; + public List DeactivatedPlans { get; } = []; + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + // A fresh plan instance per Prepare so old and new plans are + // distinguishable by reference identity. + return new WotBindingPlan(request.ResourceXid, + [], + [entry], + [], + []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + ActivatedPlans.Add(plan); + m_recorder.Record("activate", plan); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + DeactivatedPlans.Add(plan); + m_recorder.Record("deactivate", plan); + return default; + } + + private readonly PlanRecorder m_recorder; + } + + private sealed class RecordingProjectionHost : IWotProjectionHost + { + public RecordingProjectionHost(List timeline) + { + m_timeline = timeline; + } + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_timeline.Add("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_timeline.Add("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_timeline.Add("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_timeline.Add("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + { + return new(document.ClosureKey, 1, new FakeWotProjectionRegistration(), [], 0); + } + + private readonly List m_timeline; + } + + private sealed class RecordingBinderRegistry : IWotBinderRegistry + { + public RecordingBinderRegistry(List timeline) + { + m_timeline = timeline; + } + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + return new WotBindingPlan(request.ResourceXid, + [], + [entry], + [], + []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("activate"); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("deactivate"); + return default; + } + + private readonly List m_timeline; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs new file mode 100644 index 0000000000..189e27d3ab --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs @@ -0,0 +1,436 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering + /// Resolve, ExtractReferences edge cases, closure + /// diagnostics, and the and + /// value objects. + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotDependencyGraphExtendedTests + { + private static async Task SnapshotAsync( + params (WoTDocumentKindEnum Kind, string Id, string ThingId, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, _, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + private static async Task SnapshotAsync( + params (WoTDocumentKindEnum Kind, string Id, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + [Test] + public async Task ResolveWithNullSnapshotReturnsNull() + { + WotResource? result = WotDependencyGraph.Resolve(null!, "urn:some"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithNullHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, null!); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithEmptyHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, string.Empty); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithWhitespaceHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, " "); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveByThingIdFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm-res", TestMaterialization.Tm("urn:my-thing"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:my-thing"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("tm-res")); + } + + [Test] + public async Task ResolveByXidFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "my-tm", + TestMaterialization.Tm("urn:other"))); + + string xid = snapshot.AllResources().First().Xid; + WotResource? result = WotDependencyGraph.Resolve(snapshot, xid); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("my-tm")); + } + + [Test] + public async Task ResolveByResourceIdFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "my-td", + TestMaterialization.Td("urn:td"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "my-td"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("my-td")); + } + + [Test] + public async Task ResolveWithFragmentTrimsFragment() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm-frag", + TestMaterialization.Tm("urn:frag-thing"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:frag-thing#someProperty"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("tm-frag")); + } + + [Test] + public async Task ResolveByRegistryUriFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "myresource", + TestMaterialization.Tm("urn:id"))); + + string group = WotRegistryGroups.ThingModels; + string uri = $"urn:wot:{group}/myresource"; + WotResource? result = WotDependencyGraph.Resolve(snapshot, uri); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("myresource")); + } + + [Test] + public async Task ResolvePrefersTmOverTd() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tmx", TestMaterialization.Tm("urn:shared-id")), + (WoTDocumentKindEnum.ThingDescription, "tdx", + TestMaterialization.Td("urn:shared-id"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:shared-id"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Kind, Is.EqualTo(WoTDocumentKindEnum.ThingModel), + "TM should be preferred over TD when both match the same href."); + } + + [Test] + public void ExtractReferencesReturnsEmptyForInvalidJson() + { + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(TestMaterialization.InvalidJson(), 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesReturnsEmptyForNonObjectJson() + { + byte[] arrayJson = Encoding.UTF8.GetBytes("[1,2,3]"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(arrayJson, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesFindsLinksRelType() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"type\",\"href\":\"urn:tm-base\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:tm-base" && r.RefType == "type"), Is.True); + } + + [Test] + public void ExtractReferencesFindsLinksRelTmSubmodel() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"tm:submodel\",\"href\":\"urn:sub\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:sub" && r.RefType == "tm:submodel"), Is.True); + } + + [Test] + public void ExtractReferencesIgnoresLinksWithUnrecognizedRel() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"related\",\"href\":\"urn:other\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesIgnoresLinksWithoutHref() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"tm:extends\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesFindsTmRef() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"properties\":{\"p1\":{\"tm:ref\":\"urn:base#/properties/p1\"}}}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.RefType == "tm:ref"), Is.True); + } + + [Test] + public void ExtractReferencesFindsTmExtendsArray() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"tm:extends\":[\"urn:base1\",\"urn:base2\"]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Count(r => r.RefType == "tm:extends"), Is.EqualTo(2)); + } + + [Test] + public void ExtractReferencesFindsTmExtendsObjectWithHref() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"tm:extends\":[{\"href\":\"urn:obj-base\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:obj-base"), Is.True); + } + + [Test] + public async Task BuildClosuresEmptySelectionReturnsEmpty() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [], 64); + Assert.That(closures, Is.Empty); + } + + [Test] + public async Task BuildClosuresDiagnosticsIncludesMissingDependencyMessage() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:nonexistent"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.HasMissingDependency, Is.True); + Assert.That(closure.Diagnostics, Has.Length.GreaterThan(0)); + Assert.That(closure.Diagnostics.Any(d => d.Contains("urn:nonexistent", StringComparison.Ordinal)), Is.True); + } + + [Test] + public async Task BuildClosuresDiagnosticsIncludesCycleMessage() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "a", + TestMaterialization.Tm("urn:a", extendsHrefs: "urn:b")), + (WoTDocumentKindEnum.ThingModel, "b", + TestMaterialization.Tm("urn:b", extendsHrefs: "urn:a"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.HasCycle, Is.True); + Assert.That(closure.Diagnostics, Has.Length.GreaterThan(0)); + Assert.That(closure.Diagnostics.Any(d => d.Contains("cycle", StringComparison.Ordinal)), Is.True); + } + + [Test] + public async Task BuildClosuresClosureKeyIsJoinedSortedXids() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", + TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + string expectedKey = string.Join("|", + closure.Members.OrderBy(m => m.Xid, StringComparer.Ordinal).Select(m => m.Xid)); + Assert.That(closure.Key, Is.EqualTo(expectedKey)); + } + + [Test] + public async Task BuildClosuresDependenciesAreRecorded() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", + TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.Dependencies, Is.Not.Empty); + WotDependency dep = closure.Dependencies[0]; + Assert.That(dep.Resolved, Is.True); + Assert.That(dep.TargetHref, Is.EqualTo("urn:tm")); + Assert.That(dep.RefType, Is.EqualTo("tm:extends")); + } + + [Test] + public async Task BuildClosuresDiamondPatternYieldsSingleClosure() + { + // td → tmA and td → tmB, tmA → tmBase, tmB → tmBase + // All four in one closure (all reachable from td). + byte[] tmBase = TestMaterialization.Tm("urn:base"); + byte[] tmA = TestMaterialization.Tm("urn:a", extendsHrefs: "urn:base"); + byte[] tmB = TestMaterialization.Tm("urn:b", extendsHrefs: "urn:base"); + byte[] td = Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"id\":\"urn:td\"," + + "\"title\":\"td\"," + + "\"links\":[{\"rel\":\"tm:extends\",\"href\":\"urn:a\"}," + + "{\"rel\":\"tm:extends\",\"href\":\"urn:b\"}]}"); + + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "base", tmBase), + (WoTDocumentKindEnum.ThingModel, "a", tmA), + (WoTDocumentKindEnum.ThingModel, "b", tmB), + (WoTDocumentKindEnum.ThingDescription, "td", td)); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1), + "Diamond dependency should group all four resources into one closure."); + Assert.That(closures[0].IsProjectable, Is.True); + Assert.That(closures[0].Members, Has.Length.EqualTo(4)); + } + + [Test] + public void WotDependencyPropertiesAreCorrect() + { + var dep = new WotDependency( + sourceXid: "/groups/g/resources/r1", + targetHref: "urn:target", + targetXid: "/groups/g/resources/r2", + refType: "tm:extends", + resolved: true); + + Assert.That(dep.SourceXid, Is.EqualTo("/groups/g/resources/r1")); + Assert.That(dep.TargetHref, Is.EqualTo("urn:target")); + Assert.That(dep.TargetXid, Is.EqualTo("/groups/g/resources/r2")); + Assert.That(dep.RefType, Is.EqualTo("tm:extends")); + Assert.That(dep.Resolved, Is.True); + } + + [Test] + public void WotDependencyUnresolvedPropertiesAreCorrect() + { + var dep = new WotDependency( + sourceXid: "/groups/g/resources/r1", + targetHref: "urn:missing", + targetXid: null, + refType: "tm:extends", + resolved: false); + + Assert.That(dep.TargetXid, Is.Null); + Assert.That(dep.Resolved, Is.False); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs new file mode 100644 index 0000000000..5faa5d87f0 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs @@ -0,0 +1,147 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the TD/TM dependency graph: reference extraction, closure + /// partitioning (weakly-connected components), topological ordering, and + /// missing-dependency and cycle detection. + /// + [TestFixture] + public sealed class WotDependencyGraphTests + { + private static readonly string[] s_tmTdResourceIds = ["tm", "td"]; + + private async Task Snapshot( + params (WoTDocumentKindEnum Kind, string Id, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + [Test] + public void ExtractReferencesFindsTmExtendsLinks() + { + byte[] doc = TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm-1"); + + IReadOnlyList<(string Href, string RefType)> references = + WotDependencyGraph.ExtractReferences(doc, 64); + + Assert.That(references.Any(r => r.Href == "urn:tm-1" && r.RefType == "tm:extends"), + Is.True); + } + + [Test] + public async Task BuildClosuresSharedModelYieldsSingleClosureTmFirst() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].IsProjectable, Is.True); + Assert.That( + closures[0].OrderedResources.Select(r => r.ResourceId), + Is.EqualTo(s_tmTdResourceIds)); + } + + [Test] + public async Task BuildClosuresIndependentResourcesYieldSeparateClosures() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "a", TestMaterialization.Td("urn:a")), + (WoTDocumentKindEnum.ThingDescription, "b", TestMaterialization.Td("urn:b"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(2)); + Assert.That(closures.All(c => c.OrderedResources.Length == 1), Is.True); + } + + [Test] + public async Task BuildClosuresMissingDependencyIsFlagged() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:missing"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].HasMissingDependency, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + } + + [Test] + public async Task BuildClosuresCycleIsDetected() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "a", + TestMaterialization.Tm("urn:a", extendsHrefs: "urn:b")), + (WoTDocumentKindEnum.ThingModel, "b", + TestMaterialization.Tm("urn:b", extendsHrefs: "urn:a"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].HasCycle, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + Assert.That(closures[0].Members, Has.Length.EqualTo(2), + "A cyclic closure must still report its members for diagnostics."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs new file mode 100644 index 0000000000..5acdb53b02 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs @@ -0,0 +1,185 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Tests for factory methods and the + /// production implementation. + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotDocumentConverterTests + { + [Test] + public void FailureOutputSucceededIsFalse() + { + WotConversionOutput output = WotConversionOutput.Failure("Something went wrong."); + + Assert.That(output.Succeeded, Is.False); + Assert.That(output.NodeSet, Is.Null); + Assert.That(output.Errors, Has.Length.EqualTo(1)); + Assert.That(output.Errors[0], Does.Contain("Something went wrong.")); + } + + [Test] + public void FailureOutputWithMultipleErrors() + { + WotConversionOutput output = WotConversionOutput.Failure("error1", "error2"); + + Assert.That(output.Errors, Has.Length.EqualTo(2)); + } + + [Test] + public void SuccessOutputSucceededIsTrue() + { + var nodeSet = new UANodeSet(); + WotConversionOutput output = WotConversionOutput.Success(nodeSet); + + Assert.That(output.Succeeded, Is.True); + Assert.That(output.NodeSet, Is.SameAs(nodeSet)); + Assert.That(output.Errors, Is.Empty); + } + + [Test] + public void ConstructorWithDefaultErrorsIsEmpty() + { + var output = new WotConversionOutput(null, default); + + Assert.That(output.Errors, Is.Empty); + Assert.That(output.Succeeded, Is.False); + } + + [Test] + public async Task NodeSetDocumentConverterConvertsValidThingModel() + { + var converter = new WotNodeSetDocumentConverter(); + byte[] content = TestMaterialization.Tm("urn:test-tm"); + + var version = new WotResourceVersion( + versionId: "v1", + content: content, + contentType: "application/tm+json", + format: "WoT-TM/1.0", + createdAt: default, + modifiedAt: default); + var resource = new WotResource( + groupId: WotRegistryGroups.ThingModels, + resourceId: "test-tm", + kind: WoTDocumentKindEnum.ThingModel, + versions: ImmutableArray.Create(version), + defaultVersionId: "v1"); + + using var service = new WotRegistryService(); + WotRegistrySnapshot snapshot = service.Current; + + WotConversionOutput output = await converter + .ConvertAsync(resource, content, snapshot, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(output, Is.Not.Null); + } + + [Test] + public async Task NodeSetDocumentConverterFailsOnInvalidJson() + { + var converter = new WotNodeSetDocumentConverter(); + byte[] invalidContent = TestMaterialization.InvalidJson(); + + var version = new WotResourceVersion( + versionId: "v1", + content: invalidContent, + contentType: "application/td+json", + format: "WoT-TD/1.1", + createdAt: default, + modifiedAt: default); + var resource = new WotResource( + groupId: WotRegistryGroups.ThingDescriptions, + resourceId: "bad", + kind: WoTDocumentKindEnum.ThingDescription, + versions: ImmutableArray.Create(version), + defaultVersionId: "v1"); + + using var service = new WotRegistryService(); + WotRegistrySnapshot snapshot = service.Current; + + WotConversionOutput output = await converter + .ConvertAsync(resource, invalidContent, snapshot, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(output.Succeeded, Is.False); + Assert.That(output.Errors, Is.Not.Empty); + } + + [Test] + public void NodeSetDocumentConverterCanBeInstantiatedWithoutOptions() + { + var converter = new WotNodeSetDocumentConverter(); + Assert.That(converter, Is.Not.Null); + } + + [Test] + public void NodeSetDocumentConverterCanBeInstantiatedWithOptions() + { + var options = new WotNodeSetConverterOptions(); + var converter = new WotNodeSetDocumentConverter(options); + Assert.That(converter, Is.Not.Null); + } + + [Test] + public void SuccessOutputFromConstructorWithNonDefaultErrors() + { + var nodeSet = new UANodeSet(); + var errors = ImmutableArray.Empty; + var output = new WotConversionOutput(nodeSet, errors); + + Assert.That(output.Succeeded, Is.True); + Assert.That(output.Errors, Is.Empty); + } + + [Test] + public void FailureOutputRootNodeIdIsNull() + { + WotConversionOutput output = WotConversionOutput.Failure("fail"); + + Assert.That(output.RootNodeId.IsNull, Is.True); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs new file mode 100644 index 0000000000..a9d1b77ba7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs @@ -0,0 +1,374 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering + /// cycle detection, RemoveAllAsync, selection filters, force refresh, + /// and the LoadFailure and BindingFailure event kinds. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotMaterializationCoordinatorExtendedTests + { + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + } + + [TearDown] + public void TearDown() + { + m_coordinator.Dispose(); + m_registry.Dispose(); + } + + private Task RegisterTd(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + private Task RegisterTm(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingModel, + Content = content + }).AsTask(); + } + + [Test] + public async Task CyclicTmsAreNotProjectedAndEmitLoadFailureEvent() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a", extendsHrefs: "urn:tm-b")); + await RegisterTm("tm-b", TestMaterialization.Tm("urn:tm-b", extendsHrefs: "urn:tm-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A cyclic closure must not project."); + Assert.That(result.Results, Has.Length.EqualTo(2)); + Assert.That(result.Results.All(r => r.Outcome == WoTOutcomeEnum.Failed), Is.True); + Assert.That(result.Results.All(r => r.Phase == WoTPhaseEnum.DependencyResolution), + Is.True); + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.LoadFailure), + Is.True, "Each cyclic closure member must raise a LoadFailure event."); + } + + [Test] + public async Task RemoveAllAsyncRetiresPreviouslyProjectedClosures() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await m_coordinator.RemoveAllAsync(); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "RemoveAllAsync must retire the live projection."); + } + + [Test] + public async Task ForceRefreshReprocessesUnchangedContent() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { Force = true } + }); + + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "A forced refresh must shadow-reload even when the content is unchanged."); + Assert.That( + second.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.Not.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task SelectionFilterLimitsResultsToSelectedResources() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + + string tdAXid = m_registry.Current + .FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!.Xid; + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Selection = [new WoTResourceSelectorDataType + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "td-a" + }] + }); + + Assert.That(result.Results.All(r => r.ResourceId == "td-a"), Is.True, + "Filtered refresh must only return results for selected resources."); + } + + [Test] + public async Task RefreshAllEmptyResourceRegistryReturnsEmptyResults() + { + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(result.Results, Is.Empty); + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task RefreshGenerationIncrements() + { + uint before = m_coordinator.Generation; + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_coordinator.Generation, Is.GreaterThan(before)); + } + + [Test] + public async Task RefreshCompletedEventIsRaisedAfterRefresh() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.RefreshCompleted), + Is.True, "A RefreshCompleted event must be raised after every refresh."); + } + + [Test] + public async Task ValidationFailureEventIsRaisedForInvalidContent() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.ValidationFailure), + Is.True, "A ValidationFailure event must be raised for invalid content."); + } + + [Test] + public async Task LoadFailureEventIsRaisedForMissingDependency() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", + TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:missing-tm")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.LoadFailure), + Is.True, "A LoadFailure event must be raised for each resource in an unresolvable closure."); + } + + [Test] + public async Task TwoIndependentClosuresProjectAsTwoSeparateRegistrations() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(2), + "Two independent closures must each be added as a separate projection."); + Assert.That(result.Results, Has.Length.EqualTo(2)); + } + + [Test] + public async Task ResourceEventIsRaisedAfterSuccessfulProjection() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.Resource && + e.ResourceId == "td-a"), + Is.True, "A Resource event must be raised for each successfully projected resource."); + } + + [Test] + public async Task OversizedResolverResponseIsReportedAsBindingFailure() + { + var events = new List(); + var resolver = new RecordingResolver(_ => new MemoryStream(new byte[16])); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + converterOptions: new WotNodeSetConverterOptions { MaxResolverDocumentBytes = 8 }, + documentConverter: m_converter, + nodeSetResolver: resolver); + m_converter.RequiredNamespace = "urn:oversized"; + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.Multiple(() => + { + Assert.That(result.Results.Single().Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(resolver.RequestedNamespaces, Does.Contain("urn:oversized")); + Assert.That( + events.Any(e => + e.Kind == WotMaterializationEventKind.BindingFailure && + e.Reason.Contains("exceeded", StringComparison.Ordinal)), + Is.True); + }); + } + + [Test] + public async Task RetiredProjectionNamespaceIsResolvedAgainDespiteStaleNamespaceTableEntry() + { + var namespaces = new NamespaceTable(); + namespaces.GetIndexOrAppend("urn:wot:thingdescriptions/old"); + var resolver = new RecordingResolver( + uri => new MemoryStream(TestNodeSets.XmlBytes(uri))); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: resolver) + { + ServerNamespaceUris = namespaces + }; + + await RegisterTd("old", TestMaterialization.Td("urn:old")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "old"); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + m_converter.RequiredNamespace = "urn:wot:thingdescriptions/old"; + await RegisterTd("new", TestMaterialization.Td("urn:new")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(resolver.RequestedNamespaces, Does.Contain("urn:wot:thingdescriptions/old")); + } + + private sealed class RecordingResolver : IWotNodeSetResolver + { + public RecordingResolver(Func resolve) + { + m_resolve = resolve; + } + + public List RequestedNamespaces { get; } = []; + + public ValueTask TryResolveAsync( + string namespaceUri, + CancellationToken cancellationToken = default) + { + RequestedNamespaces.Add(namespaceUri); + return new ValueTask(m_resolve(namespaceUri)); + } + + private readonly Func m_resolve; + } + + [Test] + public async Task BindingCapabilitiesIsNonNullEvenWithNoBinders() + { + Assert.That(m_coordinator.BindingCapabilities, Is.Not.Null); + Assert.That(m_coordinator.BindingCapabilities, Is.Empty); + } + + [Test] + public async Task RemoveAllAsyncOnEmptyCoordinatorDoesNotThrow() + { + await m_coordinator.RemoveAllAsync(); + } + + [Test] + public async Task CyclicClosureEmitsLoadFailureForEachMember() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTm("tm-x", TestMaterialization.Tm("urn:tm-x", extendsHrefs: "urn:tm-y")); + await RegisterTm("tm-y", TestMaterialization.Tm("urn:tm-y", extendsHrefs: "urn:tm-x")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + List failures = events + .Where(e => e.Kind == WotMaterializationEventKind.LoadFailure) + .ToList(); + Assert.That(failures, Has.Count.EqualTo(2), + "Each member of a cyclic closure must receive its own LoadFailure event."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs new file mode 100644 index 0000000000..d79c146f4d --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs @@ -0,0 +1,441 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator against a recording projection + /// host and a deterministic converter, covering the dependency-closure, + /// unchanged-refresh, invalid-retention, shadow-reload and retirement + /// behaviours required by the WoT Connectivity V2 runtime. + /// + [TestFixture] + public sealed class WotMaterializationCoordinatorTests + { + private static readonly string[] s_tmTdSourceNames = ["tm-a", "td-a"]; + + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + } + + [TearDown] + public void TearDown() + { + m_coordinator.Dispose(); + m_registry.Dispose(); + } + + private Task RegisterTd(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + private Task RegisterTm(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingModel, + Content = content + }).AsTask(); + } + + [Test] + public async Task TmBeforeTdCreatesSingleClosureTmOrderedFirst() + { + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "A shared closure must project as one runtime NodeManager."); + HostOperation op = m_host.Operations.Single(o => o.Op == "add"); + Assert.That(op.SourceNames, Is.EqualTo(s_tmTdSourceNames), + "Thing Models must be ordered before the Thing Descriptions that extend them."); + // With the default (no-op) binder, affordance forms have no binder and + // materialize as degraded nodes, so the projected outcome is Warning; + // both members nonetheless reach the Active load state. + Assert.That( + result.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingModels, "tm-a")!.LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task TdBeforeTmFailsThenSucceedsAfterTmRegistration() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult first = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A Thing Description with a missing model dependency must not project."); + WoTResourceLoadResultDataType tdResult = + first.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Registering the missing model must let the closure project."); + Assert.That( + second.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task ExternalWebDependencyIsNotResolvedOutsideRegistry() + { + await RegisterTd( + "td-a", + TestMaterialization.Td( + "urn:td-a", + extendsHrefs: "https://example.invalid/models/pump.tm.jsonld")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero); + WoTResourceLoadResultDataType tdResult = + result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + } + + [Test] + public async Task UnchangedRefreshPreservesRegistrationNoModelEvent() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "No new add on an unchanged refresh."); + Assert.That(m_host.ShadowCount, Is.Zero, "No shadow reload on an unchanged refresh."); + Assert.That(m_host.ImmediateCount, Is.Zero, + "No immediate reload on an unchanged refresh."); + Assert.That( + second.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task InvalidVersionFailureRetainsPreviousActiveProjection() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + WotResource afterFirst = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + string activeBefore = afterFirst.ActiveVersionId!; + Assert.That(afterFirst.LoadState, Is.EqualTo(WoTLoadStateEnum.Active)); + + // A new version whose conversion fails. + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.Zero, + "A failed refresh must retain the previous active projection."); + WotResource afterFail = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(afterFail.LoadState, Is.EqualTo(WoTLoadStateEnum.Failed)); + Assert.That(afterFail.ActiveVersionId, Is.EqualTo(activeBefore), + "The previously active version must be retained on failure."); + Assert.That( + result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.ValidationFailure), + Is.True, "A validation failure event must be emitted."); + } + + [Test] + public async Task VersionSwitchUsesShadowReload() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "A version switch must not re-add."); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "A version switch must shadow-reload the projection."); + } + + [Test] + public async Task VersionSwitchUsesImmediateReloadWhenConfigured() + { + m_coordinator.RetirementPolicy = WotProjectionRetirementPolicy.Immediate; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.ShadowCount, Is.Zero); + Assert.That(m_host.ImmediateCount, Is.EqualTo(1), + "Immediate retirement must use the host's immediate reload path."); + } + + [Test] + public async Task VersionSwitchCleanupWarningTracksCommittedReplacement() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + m_host.NextReloadWarning = "Prior-generation cleanup is pending."; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult switched = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType result = + switched.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(result.Message, Does.Contain("cleanup is pending")); + + WotRefreshResult unchanged = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "The committed replacement handle must remain tracked after a cleanup warning."); + Assert.That( + unchanged.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task DeleteRetiresProjection() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "A deleted resource's projection must be retired."); + } + + [Test] + public async Task IndependentClosuresPartialSuccess() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + m_converter.MarkInvalid("td-b"); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Only the projectable closure commits."); + Assert.That(result.Summary.Succeeded, Is.EqualTo(1u)); + Assert.That(result.Summary.Failed, Is.EqualTo(1u)); + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-b")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + } + + [Test] + public async Task RefreshExpectedGenerationMismatchIsRejected() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + ExpectedGeneration = 99999 + }); + + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(m_host.AddCount, Is.Zero); + } + + [Test] + public async Task DryRunDoesNotCommit() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(m_host.AddCount, Is.Zero, "A dry run must not project."); + Assert.That(result.NewGeneration, Is.Zero); + } + + [Test] + public async Task DetailedResultsCarryNodeCountAndDigest() + { + m_converter.SetNodeCount("td-a", 7); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + RequestId = "req-1" + }); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.MaterializedNodeCount, Is.EqualTo(7u)); + Assert.That(td.ContentDigest.Length, Is.GreaterThan(0)); + Assert.That(result.Summary.RequestId, Is.EqualTo("req-1")); + } + + [Test] + public async Task RootNodeIdIsRecordedFromGeneratedNodeSet() + { + // The fake converter emits a NodeSet whose model namespace is + // urn:wot:{group}/{resource}; register it so the coordinator can + // resolve the recorded projection root into a server NodeId. + var namespaces = new NamespaceTable(); + string modelUri = $"urn:wot:{WotRegistryGroups.ThingDescriptions}/td-a"; + namespaces.Append(modelUri); + m_coordinator.ServerNamespaceUris = namespaces; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.RootNodeId.IsNull, Is.False, + "A document with a root must report a non-null RootNodeId."); + Assert.That(td.RootNodeId.NamespaceIndex, + Is.EqualTo((ushort)namespaces.GetIndex(modelUri))); + WotResource resource = m_registry.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(resource.RootNodeId.IsNull, Is.False); + } + + [Test] + public async Task RootNodeIdIsNullWhenNamespaceCannotBeResolved() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.RootNodeId.IsNull, Is.True, + "A document with an unresolved root namespace must report NodeId.Null."); + WotResource resource = m_registry.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(resource.RootNodeId.IsNull, Is.True); + } + + [Test] + public async Task LoadFailureEventFailedNodeIdDefaultsToNullNodeId() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WotMaterializationEventArgs failure = events.Single( + e => e.Kind == WotMaterializationEventKind.ValidationFailure); + Assert.That(failure.FailedNodeId.IsNull, Is.True); + } + + [Test] + public void FailedNodeIdCanCarryConcreteNodeId() + { + var args = new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure) + { + FailedNodeId = new NodeId(1234, 2) + }; + + Assert.That(args.FailedNodeId.IsNull, Is.False); + Assert.That(args.FailedNodeId, Is.EqualTo(new NodeId(1234, 2))); + } + + [Test] + public async Task PlaceholderResourceWithoutVersionIsNotProjected() + { + await m_registry.TryCreateResourceAsync( + WotRegistryGroups.ThingDescriptions, "empty", + WoTDocumentKindEnum.ThingDescription); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A content-less placeholder resource must not project."); + Assert.That(result.Results, Is.Empty); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs new file mode 100644 index 0000000000..c4c2b56dbb --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs @@ -0,0 +1,270 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Tests the two materialization extension points a protocol driver needs: contributing custom + /// DataTypes that have no NodeSet to import, and resolving companion-specification NodeSets a + /// Thing Description depends on but the server does not already know. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotMaterializationExtensibilityTests + { + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + } + + [TearDown] + public void TearDown() + { + m_coordinator?.Dispose(); + m_registry.Dispose(); + } + + [Test] + public async Task AContributorAddsNodesToEveryConvertedDocumentAsync() + { + var contributor = new RecordingContributor(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetContributors: [contributor]); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(contributor.Resources, Does.Contain("a"), + "A contributor must run once for the resource being materialized."); + Assert.That(contributor.SawNodesBeforeContributing, Is.True, + "A contributor must run after conversion, so the converted nodes are present."); + }); + } + + [Test] + public async Task NoContributorLeavesMaterializationUnchangedAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Registering no contributor must leave the previous behaviour untouched."); + } + + [Test] + public async Task AContributedDataTypeReachesTheProjectionAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetContributors: [new DataTypeContributor()]); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + string projected = string.Join( + "\n", + m_host.Operations.Where(o => o.Document is not null).SelectMany(o => o.Document!.Sources) + .Select(s => Encoding.UTF8.GetString(s.NodeSetXml))); + + Assert.That(projected, Does.Contain(DataTypeContributor.BrowseName), + "A DataType contributed before materialization must reach the projection, which " + + "is what lets a uav:mapByFieldPath mapping resolve against a controller UDT."); + } + + [Test] + public async Task AnUnresolvedDependencyNamespaceIsReportedNotSilentlyDroppedAsync() + { + m_converter.RequiredNamespace = kDependencyNamespace; + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: new DecliningResolver()); + + await RegisterAsync("a").ConfigureAwait(false); + WotRefreshResult result = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That( + result.Results.Any(r => r.Outcome != WoTOutcomeEnum.Success), + Is.True, + "A namespace nothing can resolve must surface, so an operator sees what is missing."); + } + + [Test] + public async Task AResolvedDependencyIsProjectedBeforeTheDocumentThatNeedsItAsync() + { + m_converter.RequiredNamespace = kDependencyNamespace; + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: new StubResolver(kDependencyNamespace)); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + WotProjectionDocument? document = m_host.Operations.LastOrDefault(o => o.Document is not null)?.Document; + Assert.That(document, Is.Not.Null); + Assert.That(document!.Sources[0].Name, Is.EqualTo(kDependencyNamespace), + "A resolved dependency must be materialized before the document that requires it."); + } + + [Test] + public async Task NoResolverLeavesAKnownDocumentUnaffectedAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "A document with no unmet dependency must not need a resolver at all."); + } + + private ValueTask RegisterAsync(string resourceId) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:" + resourceId) + }); + } + + private const string kDependencyNamespace = "urn:test:dependency"; + + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator? m_coordinator; + + private sealed class RecordingContributor : IWotNodeSetContributor + { + public List Resources { get; } = []; + + public bool SawNodesBeforeContributing { get; private set; } + + public ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default) + { + Resources.Add(resource.ResourceId); + SawNodesBeforeContributing = nodeSet.Items is { Length: > 0 }; + return default; + } + } + + private sealed class DataTypeContributor : IWotNodeSetContributor + { + public const string BrowseName = "ContributedUdt"; + + public ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default) + { + var dataType = new UADataType + { + NodeId = "ns=1;i=9000", + BrowseName = "1:" + BrowseName, + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = BrowseName }] + }; + nodeSet.Items = [.. nodeSet.Items ?? [], dataType]; + return default; + } + } + + private sealed class DecliningResolver : IWotNodeSetResolver + { + public ValueTask TryResolveAsync( + string namespaceUri, CancellationToken cancellationToken = default) + { + // Returning null is the contract's way of declining; it is not an error. + return new ValueTask((Stream?)null); + } + } + + private sealed class StubResolver : IWotNodeSetResolver + { + public StubResolver(string namespaceUri) + { + m_namespaceUri = namespaceUri; + } + + public ValueTask TryResolveAsync( + string namespaceUri, CancellationToken cancellationToken = default) + { + if (!string.Equals(namespaceUri, m_namespaceUri, StringComparison.Ordinal)) + { + return new ValueTask((Stream?)null); + } + string xml = + "" + + "" + + "" + m_namespaceUri + "" + + "" + + ""; + return new ValueTask( + (Stream?)new MemoryStream(Encoding.UTF8.GetBytes(xml))); + } + + private readonly string m_namespaceUri; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs new file mode 100644 index 0000000000..7ed6a22f23 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs @@ -0,0 +1,246 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// A test that records the add / shadow / + /// remove operations without a running server. + /// + internal sealed class FakeWotProjectionHost : IWotProjectionHost + { + public List Operations { get; } = new(); + + public int AddCount { get; private set; } + public int ShadowCount { get; private set; } + public int ImmediateCount { get; private set; } + public int RemoveCount { get; private set; } + public string NextReloadWarning { get; set; } = string.Empty; + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + AddCount++; + Operations.Add(new HostOperation("add", document)); + return new ValueTask(MakeHandle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ShadowCount++; + Operations.Add(new HostOperation("shadow", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ImmediateCount++; + Operations.Add(new HostOperation("immediate", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask RemoveAsync( + WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + RemoveCount++; + Operations.Add(new HostOperation("remove", null, handle?.ClosureKey ?? string.Empty)); + return default; + } + + private static WotProjectionHandle MakeHandle( + WotProjectionDocument document, + long gen = 1, + string warning = "") + { + return new WotProjectionHandle( + document.ClosureKey, + gen, + new FakeProjectionRegistration(), + ImmutableArray.Empty, + 0, + warning); + } + + private sealed class FakeProjectionRegistration : IWotProjectionRegistration + { + public Guid Id { get; } = Guid.NewGuid(); + } + } + + /// + /// Stands in for a host-specific projection registration in tests that do + /// not exercise a real NodeManager lifecycle. + /// + internal sealed class FakeWotProjectionRegistration : IWotProjectionRegistration + { + /// + public Guid Id { get; } = Guid.NewGuid(); + } + + internal sealed class HostOperation + { + public HostOperation(string op, WotProjectionDocument? document, string closureKey = "") + { + Op = op; + Document = document; + ClosureKey = document?.ClosureKey ?? closureKey; + } + + public string Op { get; } + public WotProjectionDocument? Document { get; } + public string ClosureKey { get; } + + public IReadOnlyList SourceNames + { + get + { + var names = new List(); + if (Document is not null) + { + foreach (WotProjectionSource source in Document.Sources) + { + names.Add(source.Name); + } + } + return names; + } + } + } + + /// + /// A deterministic that returns a canned + /// NodeSet2 per resource id, or a failure for ids marked invalid. + /// + internal sealed class FakeWotDocumentConverter : IWotDocumentConverter + { + private readonly Dictionary m_nodeCounts = new(StringComparer.Ordinal); + private readonly HashSet m_invalid = new(StringComparer.Ordinal); + + public void SetNodeCount(string resourceId, int nodeCount) + => m_nodeCounts[resourceId] = nodeCount; + + public void MarkInvalid(string resourceId) => m_invalid.Add(resourceId); + + public void ClearInvalid(string resourceId) => m_invalid.Remove(resourceId); + + /// + /// When set, every converted NodeSet declares a dependency on this namespace, so the + /// coordinator has an unmet dependency to resolve. + /// + public string? RequiredNamespace { get; set; } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (m_invalid.Contains(resource.ResourceId)) + { + return new ValueTask( + WotConversionOutput.Failure($"Injected conversion failure for '{resource.ResourceId}'.")); + } + int nodeCount = m_nodeCounts.TryGetValue(resource.ResourceId, out int c) ? c : 2; + UANodeSet nodeSet = TestNodeSets.Make( + $"urn:wot:{resource.GroupId}/{resource.ResourceId}", nodeCount, RequiredNamespace); + return new ValueTask(WotConversionOutput.Success(nodeSet)); + } + } + + internal static class TestNodeSets + { + public static byte[] XmlBytes(string modelUri, string? requiredNamespace = null) + { + return Encoding.UTF8.GetBytes(Xml(modelUri, requiredNamespace)); + } + + public static UANodeSet Make( + string modelUri, int nodeCount, string? requiredNamespace = null) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(Xml(modelUri, requiredNamespace, nodeCount))); + return UANodeSet.Read(stream)!; + } + + private static string Xml( + string modelUri, + string? requiredNamespace = null, + int nodeCount = 1) + { + var builder = new StringBuilder(); + builder.Append(""); + builder.Append(""); + builder.Append("").Append(modelUri).Append(""); + builder.Append(""); + } + else + { + builder.Append(">") + .Append(""); + } + for (int i = 0; i < nodeCount; i++) + { + int id = 5000 + i; + builder.Append("Node") + .Append(i).Append(""); + } + builder.Append(""); + return builder.ToString(); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs new file mode 100644 index 0000000000..bf08a4aaf2 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs @@ -0,0 +1,554 @@ +/* ======================================================================== + * 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 System.Xml; +using Moq; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// A test that opens pre-configured + /// fake channels (or a configurable opener delegate) per compiled form, and + /// records how many times each form was opened. + /// + internal sealed class FakeWotBindingChannelFactory : IWotBindingChannelFactory + { + public int OpenCount { get; private set; } + + public List OpenedForms { get; } = []; + + public void SetChannel(WotCompiledForm form, IWotBindingChannel channel) + { + m_openers[form] = () => new ValueTask(channel); + } + + public void SetOpener(WotCompiledForm form, Func> opener) + { + m_openers[form] = opener; + } + + public ValueTask OpenChannelAsync( + WotCompiledForm form, CancellationToken cancellationToken = default) + { + OpenCount++; + OpenedForms.Add(form); + if (m_openers.TryGetValue(form, out Func>? opener)) + { + return opener(); + } + throw new InvalidOperationException($"No fake channel configured for form '{form.AffordanceName}'."); + } + + private readonly Dictionary>> m_openers = []; + } + + /// + /// A test with configurable read/write/dispose behavior. + /// + internal sealed class FakeWotBindingChannel : IWotBindingChannel + { + public FakeWotBindingChannel(WotCompiledForm form) + { + Form = form; + } + + public WotCompiledForm Form { get; } + + public Func>? OnRead { get; set; } + + public Func>? OnWrite { get; set; } + + public Func? OnDispose { get; set; } + + public int ReadCount { get; private set; } + + public int WriteCount { get; private set; } + + public int DisposeCount { get; private set; } + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + ReadCount++; + return OnRead?.Invoke(cancellationToken) + ?? new ValueTask(new WotReadResult(StatusCodes.Good, new DataValue(Variant.Null))); + } + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + WriteCount++; + return OnWrite?.Invoke(value, cancellationToken) + ?? new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public async ValueTask DisposeAsync() + { + DisposeCount++; + if (OnDispose is not null) + { + await OnDispose().ConfigureAwait(false); + } + } + } + + /// + /// A hand-crafted nested structure used to exercise uav:mapByFieldPath nesting. + /// + internal sealed class TestChildStructure : IEncodeable, IStructure + { + public int X { get; set; } + + public ExpandedNodeId TypeId => TestChildType.EncodingId; + + public ExpandedNodeId BinaryEncodingId => TestChildType.EncodingId; + + public ExpandedNodeId XmlEncodingId => TestChildType.EncodingId; + + public void Encode(IEncoder encoder) + { + } + + public void Decode(IDecoder decoder) + { + } + + public bool IsEqual(IEncodeable? encodeable) + { + return encodeable is TestChildStructure other && other.X == X; + } + + public object Clone() + { + return new TestChildStructure { X = X }; + } + + public IReadOnlyList GetFields() + { + return []; + } + + public Variant this[int index] + { + get => index == 0 ? new Variant(X) : throw new ArgumentOutOfRangeException(nameof(index)); + set + { + if (index == 0 && value.TryGetValue(out int x)) + { + X = x; + } + } + } + + public Variant this[string name] + { + get => name == "X" ? new Variant(X) : throw new ArgumentOutOfRangeException(nameof(name)); + set + { + if (name == "X" && value.TryGetValue(out int x)) + { + X = x; + } + } + } + } + + /// + /// The activator for . + /// + internal sealed class TestChildType : EncodeableType + { + public const uint NumericId = 9101; + + public static ExpandedNodeId EncodingId { get; } = new ExpandedNodeId(NumericId, TestStructureNamespace.Uri); + + public override XmlQualifiedName XmlName => new("TestChildStructure", Ua.Namespaces.OpcUaXsd); + + public override IEncodeable CreateInstance() + { + return new TestChildStructure(); + } + + public override DataTypeDefinition GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "X", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + } + ] + }; + } + } + + /// + /// A hand-crafted root structure with a scalar field, an array field and a + /// nested field, used to exercise one-level + /// and nested uav:mapByFieldPath composition. + /// + internal sealed class TestRootStructure : IEncodeable, IStructure + { + public int A { get; set; } + + public Variant ChildValue { get; set; } = Variant.Null; + + public Variant ArrayValue { get; set; } = Variant.Null; + + public ExpandedNodeId TypeId => TestRootType.EncodingId; + + public ExpandedNodeId BinaryEncodingId => TestRootType.EncodingId; + + public ExpandedNodeId XmlEncodingId => TestRootType.EncodingId; + + public void Encode(IEncoder encoder) + { + } + + public void Decode(IDecoder decoder) + { + } + + public bool IsEqual(IEncodeable? encodeable) + { + return ReferenceEquals(this, encodeable); + } + + public object Clone() + { + return new TestRootStructure { A = A, ChildValue = ChildValue, ArrayValue = ArrayValue }; + } + + public IReadOnlyList GetFields() + { + return []; + } + + public Variant this[int index] + { + get => index switch + { + 0 => new Variant(A), + 1 => ChildValue, + 2 => ArrayValue, + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + set + { + switch (index) + { + case 0: + if (value.TryGetValue(out int a)) + { + A = a; + } + break; + case 1: + ChildValue = value; + break; + case 2: + ArrayValue = value; + break; + } + } + } + + public Variant this[string name] + { + get => name switch + { + "A" => new Variant(A), + "Child" => ChildValue, + "ArrayField" => ArrayValue, + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + set + { + switch (name) + { + case "A": + if (value.TryGetValue(out int a)) + { + A = a; + } + break; + case "Child": + ChildValue = value; + break; + case "ArrayField": + ArrayValue = value; + break; + } + } + } + } + + /// + /// The activator for . + /// + internal sealed class TestRootType : EncodeableType + { + public const uint NumericId = 9100; + + public static ExpandedNodeId EncodingId { get; } = new ExpandedNodeId(NumericId, TestStructureNamespace.Uri); + + public override XmlQualifiedName XmlName => new("TestRootStructure", Ua.Namespaces.OpcUaXsd); + + public override IEncodeable CreateInstance() + { + return new TestRootStructure(); + } + + public override DataTypeDefinition GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "A", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "Child", + DataType = ExpandedNodeId.ToNodeId(TestChildType.EncodingId, namespaceUris), + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "ArrayField", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.OneDimension + } + ] + }; + } + } + + /// + /// The shared namespace URI the hand-crafted test structure types are registered under. + /// + internal static class TestStructureNamespace + { + public const string Uri = "http://test.org/UA/WotProjectionBindingRuntimeTests/"; + } + + /// + /// Builds a minimal graph (no running + /// server) with a scalar Int32 variable and a + /// typed variable, for exercising + /// directly. + /// + internal sealed class WotProjectionBindingRuntimeTestHarness + { + public NodeManagerBuilder Builder { get; } + + public ushort Ns { get; } + + public BaseDataVariableState ScalarVar { get; } + + public BaseDataVariableState StructVar { get; } + + public FakeWotBindingChannelFactory ChannelFactory { get; } = new(); + + /// + /// Initializes a new harness. + /// + /// + /// When true (the default), and + /// are registered into the harness's + /// up front, matching a NodeManager + /// activated after NodeManagerLifecycle.RefreshComplexTypesAsync + /// already populated it. When false, neither type is + /// registered, matching activation before that refresh runs; the test + /// can register them later against the same + /// instance (via + /// Builder.Context.EncodeableFactory) to simulate the refresh + /// completing after the runtime was wired. + /// + public WotProjectionBindingRuntimeTestHarness(bool registerStructureTypes = true) + { + var namespaceUris = new NamespaceTable(); + Ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + + IEncodeableFactory factory = ServiceMessageContext.CreateEmpty(null!).Factory; + if (registerStructureTypes) + { + factory.Builder + .AddEncodeableType(TestChildType.EncodingId, new TestChildType()) + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + } + + var ctx = new SystemContext(telemetry: null!) + { + NamespaceUris = namespaceUris, + EncodeableFactory = factory + }; + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", Ns), + BrowseName = new QualifiedName("Root", Ns), + DisplayName = new LocalizedText("Root") + }; + + ScalarVar = new BaseDataVariableState(root) + { + NodeId = new NodeId("Scalar", Ns), + BrowseName = new QualifiedName("Scalar", Ns), + DisplayName = new LocalizedText("Scalar"), + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + root.AddChild(ScalarVar); + + StructVar = new BaseDataVariableState(root) + { + NodeId = new NodeId("Struct", Ns), + BrowseName = new QualifiedName("Struct", Ns), + DisplayName = new LocalizedText("Struct"), + DataType = new NodeId(TestRootType.NumericId, Ns), + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + root.AddChild(StructVar); + + var byId = new Dictionary + { + [root.NodeId] = root, + [ScalarVar.NodeId] = ScalarVar, + [StructVar.NodeId] = StructVar + }; + + Builder = new NodeManagerBuilder( + ctx, + Mock.Of(), + Ns, + rootResolver: q => q == root.BrowseName ? root : null!, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n : null!, + typeIdResolver: _ => [], + dataTypeIdResolver: dataTypeId => + { + var matches = new List(); + foreach (NodeState node in byId.Values) + { + if (node is BaseVariableState v && v.DataType == dataTypeId) + { + matches.Add(node); + } + } + return matches.ToArrayOf(); + }); + } + + public string ScalarNodeIdText => $"ns={Ns};s=Scalar"; + + public string StructNodeIdText => $"ns={Ns};s=Struct"; + + public string StructTypeNodeIdText => $"ns={Ns};i={TestRootType.NumericId}"; + + public static WotCompiledForm Form( + WoTBindingCapabilityEnum operation, + WotTargetMappingDescriptor mapping, + bool executable = true, + string affordanceName = "value") + { + string opToken = operation switch + { + WoTBindingCapabilityEnum.ReadProperty => "readproperty", + WoTBindingCapabilityEnum.WriteProperty => "writeproperty", + WoTBindingCapabilityEnum.ObserveProperty => "observeproperty", + WoTBindingCapabilityEnum.InvokeAction => "invokeaction", + _ => "unknown" + }; + return new WotCompiledForm( + new WotBindingIdentity("test", "1.0", "urn:test"), + WotAffordanceKind.Property, + affordanceName, + "/properties/" + affordanceName + "/forms/0", + operation, + opToken, + new WotEndpointDescriptor("test", null, -1, "test://x"), + new WotAddressingDescriptor(affordanceName), + new WotOperationDescriptor(operation, opToken, "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], + isExecutable: executable, + targetMapping: mapping); + } + + public static WotBindingPlan Plan(params WotCompiledForm[] forms) + { + return new( + "res", + [], + [.. forms], + [], + []); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs new file mode 100644 index 0000000000..01338e72e0 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs @@ -0,0 +1,866 @@ +/* ======================================================================== + * 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.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises and + /// directly against a lightweight + /// graph (no running + /// server): direct scalar read/write, ignore rules, conflict/duplicate + /// diagnostics, lazy single-open channel caching, failed-open retry, + /// disposal, structured field composition, and generation isolation. + /// + [TestFixture] + public sealed class WotProjectionBindingRuntimeTests + { + [Test] + public async Task DirectReadReturnsChannelValuePreservingStatusAndTimestamp() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm); + var timestamp = new DateTimeUtc(2026, 1, 1, 0, 0, 0); + channel.OnRead = _ => new ValueTask(new WotReadResult( + StatusCodes.Good, + new DataValue(new Variant(42), StatusCodes.UncertainInitialValue, timestamp))); + h.ChannelFactory.SetChannel(readForm, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGoodOrUncertain(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out int read) && read == 42, Is.True); + Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.UncertainInitialValue)); + Assert.That(value.SourceTimestamp, Is.EqualTo(timestamp)); + + Assert.That(runtime, Is.Not.Null); + await runtime!.DisposeAsync().ConfigureAwait(false); + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task DirectWriteWritesThroughChannel() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(writeForm); + DataValue? written = null; + channel.OnWrite = (value, _) => + { + written = value; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeForm, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeForm)]).ConfigureAwait(false); + + ServiceResult result = await h.ScalarVar.WriteAttributeAsync( + h.Builder.Context, Attributes.Value, default, new DataValue(new Variant(7))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written, Is.Not.Null); + Assert.That(written!.Value.WrappedValue.TryGetValue(out int w) && w == 7, Is.True); + } + + [Test] + public async Task FormsWithoutTargetMappingAreIgnored() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, WotTargetMappingDescriptor.Empty); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public async Task NonExecutableFormsAreIgnored() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + executable: false); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public async Task ObserveOnlyDoesNotWireASeparateBridge() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ObserveProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ScalarVar.OnWriteValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public void ConflictingDirectAndFieldMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm direct = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "direct"); + WotCompiledForm field = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText, fieldPath: "X"), + affordanceName: "field"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(direct, field)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void DuplicateReadMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm read1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read1"); + WotCompiledForm read2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(read1, read2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void DuplicateWriteMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm write1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write1"); + WotCompiledForm write2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(write1, write2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void UnsupportedOperationThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm invoke = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.InvokeAction, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(invoke)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task LazyOpenConcurrentFirstUseOpensChannelOnce() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.ChannelFactory.SetOpener(readForm, async () => + { + await gate.Task.ConfigureAwait(false); + return channel; + }); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + Task<(ServiceResult, DataValue)> read1 = h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()).AsTask(); + Task<(ServiceResult, DataValue)> read2 = h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()).AsTask(); + + gate.SetResult(true); + await Task.WhenAll(read1, read2).ConfigureAwait(false); + + Assert.That(h.ChannelFactory.OpenCount, Is.EqualTo(1), + "Concurrent first use must open the shared channel exactly once."); + } + + [Test] + public async Task FailedOpenIsEvictedRetrySucceeds() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + int attempt = 0; + h.ChannelFactory.SetOpener(readForm, () => + { + attempt++; + if (attempt == 1) + { + return new ValueTask( + Task.FromException(new InvalidOperationException("open failed"))); + } + return new ValueTask(channel); + }); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + (ServiceResult firstResult, _) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(firstResult), Is.True, "The first (faulted) open must fail the read."); + + (ServiceResult secondResult, DataValue secondValue) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(secondResult), Is.True, "A retry after a faulted open must succeed."); + Assert.That(secondValue.WrappedValue.TryGetValue(out int v) && v == 1, Is.True); + Assert.That(attempt, Is.EqualTo(2), "The faulted open must be evicted so the retry opens again."); + } + + [Test] + public async Task DisposeDisposesOpenedChannelsAggregatesFailures() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read"); + WotCompiledForm writeForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write"); + var readChannel = new FakeWotBindingChannel(readForm); + var writeChannel = new FakeWotBindingChannel(writeForm) + { + OnDispose = () => throw new InvalidOperationException("dispose failed") + }; + h.ChannelFactory.SetChannel(readForm, readChannel); + h.ChannelFactory.SetChannel(writeForm, writeChannel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm, writeForm)]).ConfigureAwait(false); + + // Open both channels. + await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + await h.ScalarVar.WriteAttributeAsync( + h.Builder.Context, Attributes.Value, default, new DataValue(new Variant(1))).ConfigureAwait(false); + + AggregateException? ex = Assert.ThrowsAsync( + async () => await runtime!.DisposeAsync().ConfigureAwait(false)); + Assert.That(ex!.InnerExceptions, Has.Count.EqualTo(1)); + Assert.That(readChannel.DisposeCount, Is.EqualTo(1), + "A sibling channel's dispose failure must not prevent other channels from being disposed."); + Assert.That(writeChannel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task GenerationIsolationTwoRuntimesHaveIndependentChannelsAndDisposal() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel1 = new FakeWotBindingChannel(form1); + h.ChannelFactory.SetChannel(form1, channel1); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime1 = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form1)]).ConfigureAwait(false); + await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + // A second, independent generation with its own compiled form (and + // hence its own channel) targeting the same variable. + var h2 = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h2.ScalarNodeIdText)); + var channel2 = new FakeWotBindingChannel(form2); + h2.ChannelFactory.SetChannel(form2, channel2); + var factory2 = new WotProjectionBindingRuntimeFactory(h2.ChannelFactory); + IAsyncDisposable? runtime2 = await factory2.CreateAsync( + h2.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form2)]).ConfigureAwait(false); + await h2.ScalarVar.ReadAttributeAsync( + h2.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + await runtime1!.DisposeAsync().ConfigureAwait(false); + + Assert.That(channel1.DisposeCount, Is.EqualTo(1)); + Assert.That(channel2.DisposeCount, Is.Zero, + "Disposing one generation's runtime must not affect a different generation's channels."); + + await runtime2!.DisposeAsync().ConfigureAwait(false); + Assert.That(channel2.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task StructuredOneLevelReadComposesFields() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(11)))) + }; + h.ChannelFactory.SetChannel(readA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? encodeable), Is.True); + var root = (TestRootStructure)encodeable!; + Assert.That(root.A, Is.EqualTo(11)); + } + + [Test] + public async Task StructuredOneLevelWriteExtractsFields() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + var channelA = new FakeWotBindingChannel(writeA); + Variant written = default; + channelA.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA)]).ConfigureAwait(false); + + var incoming = new TestRootStructure { A = 55 }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int a) && a == 55, Is.True); + } + + [Test] + public async Task StructuredNestedReadComposesNestedStructure() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channel = new FakeWotBindingChannel(readChildX) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(99)))) + }; + h.ChannelFactory.SetChannel(readChildX, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? rootEncodeable), Is.True); + var root = (TestRootStructure)rootEncodeable!; + Assert.That(root.ChildValue.TryGetValue(out ExtensionObject childExt), Is.True); + Assert.That(childExt.TryGetValue(out IEncodeable? childEncodeable), Is.True); + var child = (TestChildStructure)childEncodeable!; + Assert.That(child.X, Is.EqualTo(99)); + } + + [Test] + public async Task StructuredNestedWriteExtractsNestedField() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "writeChildX"); + var channel = new FakeWotBindingChannel(writeChildX); + Variant written = default; + channel.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeChildX, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeChildX)]).ConfigureAwait(false); + + var incomingChild = new TestChildStructure { X = 77 }; + var incoming = new TestRootStructure { ChildValue = new Variant(new ExtensionObject(incomingChild)) }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int x) && x == 77, Is.True); + } + + [Test] + public async Task StructuredUnknownFieldFirstReadFailsDeterministically() + { + // BuildPlan validation is deferred to first use (see class remarks + // on WotStructuredGroupState): CreateAsync itself must not throw. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "NoSuchField")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredArrayValuedIntermediateFieldFirstReadFailsDeterministically() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "ArrayField/X")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredEmptyPathSegmentFirstReadFailsDeterministically() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child//X")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredUnregisteredTypeFirstReadFailsDeterministicallyThenRetrySucceedsAfterRegistration() + { + // The structure type is not yet registered when the runtime is + // wired (mirrors ConfigureAsync running before + // NodeManagerLifecycle.RefreshComplexTypesAsync). Wiring, and a + // first read attempted before registration, must both fail + // deterministically without throwing out of the request pipeline; + // once the type is registered into the same factory instance + // (simulating RefreshComplexTypesAsync) a later read must succeed. + var h = new WotProjectionBindingRuntimeTestHarness(registerStructureTypes: false); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(11)))) + }; + h.ChannelFactory.SetChannel(readA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, + "Wiring must succeed even though the structure type is not registered yet."); + + (ServiceResult beforeResult, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(beforeResult), Is.True, + "A read before the type is registered must fail deterministically, not throw."); + Assert.That(beforeResult.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + + // Simulate NodeManagerLifecycle.RefreshComplexTypesAsync completing: + // register the type into the very same factory instance the + // runtime captured at wiring time. + h.Builder.Context.EncodeableFactory.Builder + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + + (ServiceResult afterResult, DataValue afterValue) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(afterResult), Is.True, + "A read after the type is registered must retry resolution and succeed."); + Assert.That(afterValue.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? encodeable), Is.True); + Assert.That(((TestRootStructure)encodeable!).A, Is.EqualTo(11)); + } + + [Test] + public async Task StructuredUnregisteredTypeFirstWriteFailsDeterministicallyThenRetrySucceedsAfterRegistration() + { + var h = new WotProjectionBindingRuntimeTestHarness(registerStructureTypes: false); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + var channelA = new FakeWotBindingChannel(writeA); + Variant written = default; + channelA.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA)]).ConfigureAwait(false); + + var incomingBefore = new TestRootStructure { A = 1 }; + ServiceResult beforeResult = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incomingBefore)))).ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(beforeResult), Is.True, + "A write before the type is registered must fail deterministically, not throw."); + Assert.That(written.IsNull, Is.True); + + h.Builder.Context.EncodeableFactory.Builder + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + + var incomingAfter = new TestRootStructure { A = 55 }; + ServiceResult afterResult = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incomingAfter)))).ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(afterResult), Is.True, + "A write after the type is registered must retry resolution and succeed."); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int a) && a == 55, Is.True); + } + + [Test] + public async Task StructuredPartialReadFailureReturnsBadStatus() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + var channelChildX = new FakeWotBindingChannel(readChildX) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.BadNotConnected, DataValue.Null, "not connected")) + }; + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsBad(result), Is.True, + "A single failing field must fail the whole structured read."); + } + + [Test] + public async Task StructuredPartialReadFailureReturnsFailedFieldStatusAndTimestampWhenAvailable() + { + // A failed field's own status and timestamp — not a hardcoded + // Bad/Now pair — must surface on the overall structured read. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + var channelChildX = new FakeWotBindingChannel(readChildX); + var staleTimestamp = new DateTimeUtc(2020, 1, 1, 0, 0, 0); + channelChildX.OnRead = _ => new ValueTask( + new WotReadResult( + StatusCodes.BadNotConnected, + new DataValue(Variant.Null, StatusCodes.BadNotConnected, staleTimestamp), + "not connected")); + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotConnected)); + Assert.That(value.SourceTimestamp, Is.EqualTo(staleTimestamp)); + } + + [Test] + public async Task StructuredReadPreservesNonDefaultGoodStatusAndUsesOldestSourceTimestamp() + { + // The composed value must not collapse every field's metadata to + // a hardcoded Good/Now: a non-default Good status among the + // fields must survive, and the oldest field timestamp must win. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA); + var newerTimestamp = new DateTimeUtc(2026, 1, 2, 0, 0, 0); + channelA.OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1), StatusCodes.Good, newerTimestamp))); + var channelChildX = new FakeWotBindingChannel(readChildX); + var olderTimestamp = new DateTimeUtc(2026, 1, 1, 0, 0, 0); + channelChildX.OnRead = _ => new ValueTask( + new WotReadResult( + StatusCodes.GoodClamped, + new DataValue(new Variant(2), StatusCodes.GoodClamped, olderTimestamp))); + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.GoodClamped)); + Assert.That(value.SourceTimestamp, Is.EqualTo(olderTimestamp), + "The oldest non-MinValue field timestamp must be used, not the current time."); + } + + [Test] + public async Task StructuredPartialWriteFailureReturnsBadStatus() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + WotCompiledForm writeChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "writeChildX"); + var channelA = new FakeWotBindingChannel(writeA) + { + OnWrite = (_, _) => new ValueTask(new WotWriteResult(StatusCodes.Good)) + }; + var channelChildX = new FakeWotBindingChannel(writeChildX) + { + OnWrite = (_, _) => + new ValueTask(new WotWriteResult(StatusCodes.BadNotConnected, "not connected")) + }; + h.ChannelFactory.SetChannel(writeA, channelA); + h.ChannelFactory.SetChannel(writeChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA, writeChildX)]).ConfigureAwait(false); + + var incomingChild = new TestChildStructure { X = 1 }; + var incoming = new TestRootStructure + { + A = 2, + ChildValue = new Variant(new ExtensionObject(incomingChild)) + }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsBad(result), Is.True, + "A single failing field write must fail the whole structured write."); + } + + [Test] + public void StructuredDuplicateFieldMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA1"); + WotCompiledForm readA2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA1, readA2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs new file mode 100644 index 0000000000..103a302fc8 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs @@ -0,0 +1,211 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering the + /// numeric type coercions (long, ushort, byte, negative + /// int) and the Enumerate / TryCoerce edge-cases (single + /// , wrapped , and null + /// extension body). + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotRefreshArgumentsExtendedTests + { + private static IServiceMessageContext Context => ServiceMessageContext.CreateEmpty(null!); + + private static ArrayOf Args(params Variant[] values) + { + return values; + } + + [Test] + public void AcceptsExpectedGenerationAsLong() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((long)42)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(42u)); + } + + [Test] + public void AcceptsExpectedGenerationAsZeroLong() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((long)0)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.Zero); + } + + [Test] + public void AcceptsExpectedGenerationAsUshort() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((ushort)100)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(100u)); + } + + [Test] + public void AcceptsExpectedGenerationAsByte() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((byte)7)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(7u)); + } + + [Test] + public void RejectsNegativeIntForExpectedGeneration() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(-1)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsLongOutOfRangeForExpectedGeneration() + { + long overflow = (long)uint.MaxValue + 1; + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(overflow)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsSingleExtensionObjectAsSelection() + { + var selector = new WoTResourceSelectorDataType + { + ResourceId = "single-eo", + GroupId = WotRegistryGroups.ThingDescriptions + }; + var extension = new ExtensionObject(selector); + + ArrayOf input = Args(new Variant(extension)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("single-eo")); + } + + [Test] + public void RejectsNullExtensionObjectInSelection() + { + var nullExtension = new ExtensionObject(); + ArrayOf input = Args(new Variant(new[] { nullExtension })); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsIEncodeableWrappedInExtensionObjectAsOptions() + { + var options = new WoTRefreshOptionsDataType + { + Force = true, + DryRun = false + }; + var extension = new ExtensionObject(options); + + ArrayOf input = Args( + Variant.Null, + new Variant(extension)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options.Force, Is.True); + } + + [Test] + public void FullyNullArgumentsDefaultsAreNonNull() + { + ServiceResult status = WotRefreshArguments.TryDecode( + Args(Variant.Null, Variant.Null, Variant.Null, Variant.Null), + Context, + out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options, Is.Not.Null); + Assert.That(request.Selection, Is.Empty); + Assert.That(request.RequestId, Is.EqualTo(string.Empty)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs new file mode 100644 index 0000000000..d2088f4c5e --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs @@ -0,0 +1,315 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.Encoders; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Unit tests for , the decoder for the + /// generated WoTRegistryType.Refresh Method's Selection / Options / + /// ExpectedGeneration / RequestId arguments. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotRefreshArgumentsTests + { + private static IServiceMessageContext Context => ServiceMessageContext.CreateEmpty(null!); + + private static ArrayOf Args(params Variant[] values) + { + return values; + } + + [Test] + public void EmptyArgumentsDecodeToFullRefreshWithDefaults() + { + ServiceResult status = WotRefreshArguments.TryDecode( + Args(), Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Is.Empty); + Assert.That(request.ExpectedGeneration, Is.Zero); + Assert.That(request.RequestId, Is.EqualTo(string.Empty)); + Assert.That(request.Options, Is.Not.Null); + } + + [Test] + public void DecodesSelectionArrayOptionsGenerationAndRequestId() + { + var selector = new WoTResourceSelectorDataType + { + GroupId = "thingdescriptions", + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription + }; + var options = new WoTRefreshOptionsDataType + { + Force = true, + DryRun = true, + Atomicity = WoTAtomicityEnum.PerGroup, + DeletePolicy = WoTDeletePolicyEnum.Retire, + IncludeDependents = true + }; + ArrayOf input = Args( + new Variant(new ExtensionObject[] { new(selector) }), + new Variant(new ExtensionObject(options)), + new Variant(7u), + new Variant("req-42")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("sensor")); + Assert.That(request.Options.Force, Is.True); + Assert.That(request.Options.DryRun, Is.True); + Assert.That(request.Options.Atomicity, Is.EqualTo(WoTAtomicityEnum.PerGroup)); + Assert.That(request.Options.DeletePolicy, Is.EqualTo(WoTDeletePolicyEnum.Retire)); + Assert.That(request.Options.IncludeDependents, Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(7u)); + Assert.That(request.RequestId, Is.EqualTo("req-42")); + } + + [Test] + public void DecodesSelectionFromArrayOfExtensionObject() + { + var selectors = new ArrayOf(new[] + { + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/a" }), + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/b" }) + }); + ArrayOf input = Args(new Variant(selectors)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(2)); + Assert.That(request.Selection[1].Xid, Is.EqualTo("/groups/g/resources/b")); + } + + [Test] + public void RejectsSelectionOfWrongElementType() + { + string[] wrongSelection = ["not-a-selector"]; + ArrayOf input = Args(new Variant(wrongSelection)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsOptionsOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + new Variant("not-an-options-structure")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsExpectedGenerationOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant("five")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsExpectedGenerationAsInt32() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(9)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(9u)); + } + + [Test] + public void RejectsRequestIdOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + Variant.Null, + new Variant(123)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void DecodesBinaryEncodedSelectionBody() + { + IServiceMessageContext context = Context; + var selector = new WoTResourceSelectorDataType { ResourceId = "encoded" }; + byte[] encoded; + using (var encoder = new BinaryEncoder(context)) + { + selector.Encode(encoder); + encoded = encoder.CloseAndReturnBuffer()!; + } + var extension = new ExtensionObject( + DataTypeIds.WoTResourceSelectorDataType, ByteString.From(encoded)); + ArrayOf input = Args(new Variant(new[] { extension })); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("encoded")); + } + + [Test] + public void DecodesDynamicStructureOptionsBody() + { + Structure options = CreateDynamicOptions( + DataTypeIds.WoTRefreshOptionsDataType, + ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary); + ArrayOf input = Args( + Variant.Null, + new Variant(new ExtensionObject( + ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary, + options))); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, + Context, + out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options.Atomicity, Is.EqualTo(WoTAtomicityEnum.PerGroup)); + Assert.That(request.Options.Force, Is.True); + Assert.That(request.Options.DryRun, Is.True); + Assert.That(request.Options.IncludeDependents, Is.True); + Assert.That(request.Options.DeletePolicy, Is.EqualTo(WoTDeletePolicyEnum.Retire)); + Assert.That(request.Options.MaxParallelism, Is.EqualTo(4u)); + Assert.That(request.Options.Timeout, Is.EqualTo(2.5)); + } + + [Test] + public void RejectsDynamicStructureWithDifferentTypeIdentity() + { + var wrongTypeId = new ExpandedNodeId( + 9001u, + 0, + "urn:opcfoundation.org:UA:WotAggregation:WrongType", + 0); + var wrongEncodingId = new ExpandedNodeId( + 9002u, + 0, + "urn:opcfoundation.org:UA:WotAggregation:WrongType", + 0); + Structure options = CreateDynamicOptions(wrongTypeId, wrongEncodingId); + ArrayOf input = Args( + Variant.Null, + new Variant(new ExtensionObject(wrongEncodingId, options))); + + ServiceResult status = WotRefreshArguments.TryDecode(input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + private static Structure CreateDynamicOptions( + ExpandedNodeId typeId, + ExpandedNodeId binaryEncodingId) + { + StructureField[] fields = + [ + new StructureField { Name = "Atomicity", DataType = Ua.DataTypeIds.Int32 }, + new StructureField { Name = "Force", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "DryRun", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "IncludeDependents", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "DeletePolicy", DataType = Ua.DataTypeIds.Int32 }, + new StructureField { Name = "MaxParallelism", DataType = Ua.DataTypeIds.UInt32 }, + new StructureField { Name = "Timeout", DataType = Ua.DataTypeIds.Double } + ]; + var definition = new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = fields + }; + var fieldTypes = new Dictionary + { + ["Atomicity"] = BuiltInType.Enumeration, + ["Force"] = BuiltInType.Boolean, + ["DryRun"] = BuiltInType.Boolean, + ["IncludeDependents"] = BuiltInType.Boolean, + ["DeletePolicy"] = BuiltInType.Enumeration, + ["MaxParallelism"] = BuiltInType.UInt32, + ["Timeout"] = BuiltInType.Double + }; + return new Structure( + new XmlQualifiedName( + nameof(WoTRefreshOptionsDataType), + Namespaces.WotCon), + typeId, + binaryEncodingId, + ExpandedNodeId.Null, + definition, + fieldTypes) + { + ["Atomicity"] = Variant.From(WoTAtomicityEnum.PerGroup), + ["Force"] = Variant.From(true), + ["DryRun"] = Variant.From(true), + ["IncludeDependents"] = Variant.From(true), + ["DeletePolicy"] = Variant.From(WoTDeletePolicyEnum.Retire), + ["MaxParallelism"] = Variant.From(4u), + ["Timeout"] = Variant.From(2.5) + }; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs new file mode 100644 index 0000000000..5b6b086823 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises directly: a failed + /// resolution is never cached and retries against the (possibly + /// by-then-populated) factory, and concurrent first use resolves exactly + /// once under the lock. + /// + [TestFixture] + public sealed class WotStructuredGroupStateTests + { + [Test] + public void EnsureResolvedTypeUnavailableFailsWithoutCachingThenSucceedsAfterRegistration() + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + IEncodeableFactory factory = ServiceMessageContext.CreateEmpty(null!).Factory; + var dataTypeId = new NodeId(TestRootType.NumericId, ns); + var targetNodeId = new NodeId("Struct", ns); + + var state = new WotStructuredGroupState( + factory, + namespaceUris, + dataTypeId, + targetNodeId, + readSlots: [], + writeSlots: []); + + WotStructuredGroupResolution first = state.EnsureResolved(); + Assert.That(first.Success, Is.False); + Assert.That(first.Error.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(first.RootType, Is.Null); + + // Simulate NodeManagerLifecycle.RefreshComplexTypesAsync completing + // against the very same factory instance. + factory.Builder.AddEncodeableType(TestRootType.EncodingId, new TestRootType()).Commit(); + + WotStructuredGroupResolution second = state.EnsureResolved(); + Assert.That( + second.Success, Is.True, "The failed attempt must not have been cached; a retry must resolve now."); + Assert.That(second.RootType, Is.Not.Null); + } + + [Test] + public async Task EnsureResolvedConcurrentFirstCallsResolveExactlyOnceAndAllSucceed() + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + IEncodeableFactory inner = ServiceMessageContext.CreateEmpty(null!).Factory; + inner.Builder.AddEncodeableType(TestRootType.EncodingId, new TestRootType()).Commit(); + var counting = new CountingEncodeableFactory(inner); + var dataTypeId = new NodeId(TestRootType.NumericId, ns); + var targetNodeId = new NodeId("Struct", ns); + + var state = new WotStructuredGroupState( + counting, + namespaceUris, + dataTypeId, + targetNodeId, + readSlots: [], + writeSlots: []); + + const int callerCount = 8; + var barrier = new Barrier(callerCount); + var tasks = new Task[callerCount]; + for (int i = 0; i < callerCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.SignalAndWait(); + return state.EnsureResolved(); + }); + } + WotStructuredGroupResolution[] results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (WotStructuredGroupResolution result in results) + { + Assert.That(result.Success, Is.True); + } + Assert.That(counting.TryGetEncodeableTypeCallCount, Is.EqualTo(1), + "Concurrent first use must resolve exactly once under the lock, not once per caller."); + } + + /// + /// A minimal decorator that forwards + /// every lookup to an inner factory while counting + /// calls, so a test can assert how + /// many times resolution actually ran. + /// + private sealed class CountingEncodeableFactory : IEncodeableFactory + { + public CountingEncodeableFactory(IEncodeableFactory inner) + { + m_inner = inner; + } + + public IEnumerable KnownTypeIds => m_inner.KnownTypeIds; + + public IEncodeableFactoryBuilder Builder => m_inner.Builder; + + public int TryGetEncodeableTypeCallCount => m_count; + + public bool TryGetEncodeableType( + ExpandedNodeId typeId, + [NotNullWhen(true)] out IEncodeableType? encodeableType) + { + Interlocked.Increment(ref m_count); + return m_inner.TryGetEncodeableType(typeId, out encodeableType); + } + + public bool TryGetEnumeratedType( + ExpandedNodeId typeId, + [NotNullWhen(true)] out IEnumeratedType? enumeratedType) + { + return m_inner.TryGetEnumeratedType(typeId, out enumeratedType); + } + + public bool TryGetType( + XmlQualifiedName xmlName, + [NotNullWhen(true)] out IType? type) + { + return m_inner.TryGetType(xmlName, out type); + } + + private readonly IEncodeableFactory m_inner; + private int m_count; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs new file mode 100644 index 0000000000..17ddb2a3e3 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * 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.Collections.Generic; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises against a lightweight + /// graph (no running server): exact NodeId + /// resolution, type-only unique/ambiguous resolution, exact+type validation, + /// and the deterministic failure statuses for missing, malformed, ambiguous, + /// wrong-node-class and type-mismatch mappings. + /// + [TestFixture] + public sealed class WotTargetVariableResolverTests + { + private const string NsUri = "http://test.org/UA/Wot/"; + + [Test] + public void MapToNodeIdResolvesExactVariable() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToNodeIdPortableNsuFormResolvesAgainstNamespaceUris() + { + (NodeManagerBuilder builder, _, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetNodeId: $"nsu={NsUri};s=Var1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToNodeIdMissingNodeThrowsBadNodeIdUnknown() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=NoSuchVar"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void MapToNodeIdMalformedTextThrowsBadNodeIdInvalid() + { + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: "not a node id"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + + [Test] + public void MapToNodeIdMalformedTextMessageNamesTheOffendingTerm() + { + // The parser's own ServiceResultException (BadNodeIdInvalid) must + // be wrapped, not rethrown verbatim, so the message names which + // target-mapping term ('uav:mapToNodeId') carried the bad text. + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: "not a node id"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + Assert.That(ex.Message, Does.Contain("uav:mapToNodeId")); + } + + [Test] + public void MapToTypeUnresolvableNamespaceUriWrapsServiceResultExceptionAsBadNodeIdInvalid() + { + // 'nsu=' referencing a namespace absent from the builder's table + // makes the parser itself throw a ServiceResultException; that + // must still be wrapped as BadNodeIdInvalid naming 'uav:mapToType', + // not rethrown with the parser's own (unrelated) message. + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetTypeNodeId: "nsu=http://no.such.namespace/;i=1"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + Assert.That(ex.Message, Does.Contain("uav:mapToType")); + } + + [Test] + public void MapToNodeIdWrongNodeClassThrowsBadTypeMismatch() + { + (NodeManagerBuilder builder, ushort ns, _, _, BaseObjectState obj) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor( + targetNodeId: $"ns={ns};s={obj.NodeId.IdentifierAsString}"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void MapToTypeUniqueResolvesVariable() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToTypeAmbiguousThrowsBadBrowseNameDuplicated() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(secondVariableSameType: true); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=1"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + } + + [Test] + public void MapToTypeNoMatchThrowsBadNodeIdUnknown() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=999"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void BothMatchingDataTypeResolvesAndValidates() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1", targetTypeNodeId: $"ns={ns};i=1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void BothMismatchingDataTypeThrowsBadTypeMismatch() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1", targetTypeNodeId: $"ns={ns};i=2"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void NeitherTermPresentThrowsBadNodeIdInvalid() + { + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, WotTargetMappingDescriptor.Empty)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + + private static (NodeManagerBuilder Builder, ushort Ns, BaseDataVariableState Var1, + BaseDataVariableState? Var2, BaseObjectState Obj) CreateGraph(bool secondVariableSameType = false) + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(NsUri); + + var ctx = new SystemContext(telemetry: null!) + { + NamespaceUris = namespaceUris + }; + + var dataType1 = new NodeId(1, ns); + var dataType2 = new NodeId(2, ns); + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", ns), + BrowseName = new QualifiedName("Root", ns), + DisplayName = new LocalizedText("Root") + }; + + var var1 = new BaseDataVariableState(root) + { + NodeId = new NodeId("Var1", ns), + BrowseName = new QualifiedName("Var1", ns), + DisplayName = new LocalizedText("Var1"), + DataType = dataType1, + ValueRank = ValueRanks.Scalar + }; + root.AddChild(var1); + + BaseDataVariableState? var2 = null; + if (secondVariableSameType) + { + var2 = new BaseDataVariableState(root) + { + NodeId = new NodeId("Var2", ns), + BrowseName = new QualifiedName("Var2", ns), + DisplayName = new LocalizedText("Var2"), + DataType = dataType1, + ValueRank = ValueRanks.Scalar + }; + root.AddChild(var2); + } + + var byId = new Dictionary + { + [root.NodeId] = root, + [var1.NodeId] = var1 + }; + if (var2 is not null) + { + byId[var2.NodeId] = var2; + } + + var builder = new NodeManagerBuilder( + ctx, + nodeManager: Mock.Of(), + defaultNamespaceIndex: ns, + rootResolver: q => q == root.BrowseName ? root : null!, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n : null!, + typeIdResolver: _ => [], + dataTypeIdResolver: dataTypeId => + { + var matches = new List(); + foreach (NodeState node in byId.Values) + { + if (node is BaseVariableState v && v.DataType == dataTypeId) + { + matches.Add(node); + } + } + return matches.ToArrayOf(); + }); + + return (builder, ns, var1, var2, root); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj index ca9d37fdc7..aa9aed8372 100644 --- a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj +++ b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj @@ -34,7 +34,11 @@ + + + + diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/WotBlobResourceStoreTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/WotBlobResourceStoreTests.cs index b883c60b88..2e196b4d27 100644 --- a/tests/Opc.Ua.WotCon.Tests/Registry/WotBlobResourceStoreTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Registry/WotBlobResourceStoreTests.cs @@ -185,6 +185,9 @@ public System.DateTime GetLastWriteTime(string path) public long GetLength(string path) => m_inner.GetLength(path); + public void Replace(string sourceFileName, string destinationFileName) + => m_inner.Replace(sourceFileName, destinationFileName); + private readonly IFileSystem m_inner; } diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs new file mode 100644 index 0000000000..dde714653d --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs @@ -0,0 +1,501 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.WotCon; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; +using UaBrowseNames = global::Opc.Ua.BrowseNames; +using UaObjectIds = global::Opc.Ua.ObjectIds; +using UaObjectTypeIds = global::Opc.Ua.ObjectTypeIds; + +#nullable disable warnings + +namespace Opc.Ua.WotCon.Tests.RuntimeNodeSet +{ + /// + /// End-to-end tests that a real subscription with a real + /// receives the generated WoT V2 event types through + /// the running server's notifier chain, and that every typed event field + /// populated by from the coordinator's + /// event arguments is delivered and resolvable via the filter's + /// select clauses. + /// + [TestFixture] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryEventIntegrationTests + { + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryEventIntegrationTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + var options = new WotRegistryServerOptions + { + // Refreshes are triggered explicitly by the tests so the raised + // events are deterministic. + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SelectiveConverter()); + var factory = new WotRegistryNodeManagerFactory(options, m_registry, m_coordinator); + await m_server.NodeManagerLifecycle.AddAsync(factory).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_requestHeader is not null) + { + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RefreshCompletedEventDeliversPopulatedSummaryFieldsThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + // Trigger a refresh: the registry raises a RefreshCompleted event with + // the request id, the committed generation and the refresh summary. + const string RequestId = "req-42"; + WotRefreshResult result = await m_coordinator + .RefreshAsync(new WotRefreshRequest { RequestId = RequestId }) + .ConfigureAwait(false); + + var refreshCompletedType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTRefreshCompletedEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == refreshCompletedType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The RefreshCompleted event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.RequestId]), Is.EqualTo(RequestId), + "RequestId must be populated from the materialization event arguments."); + Assert.That(AsUInt32(fields[Field.Generation]), Is.EqualTo(result.NewGeneration), + "The event's Generation must match the committed refresh generation."); + Assert.That( + fields[Field.Summary].TryGetValue(out ExtensionObject summaryEo), Is.True, + "The refresh Summary structure field must be populated."); + Assert.That(summaryEo.TryGetValue(out WoTRefreshSummaryDataType? summary), Is.True); + Assert.That(summary!.RequestId, Is.EqualTo(RequestId), + "The Summary must carry the originating refresh request id."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ResourceEventDeliversPopulatedIdentityFieldsThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("sensor") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + var resourceType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTResourceEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == resourceType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The resource activation event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("sensor")); + Assert.That(AsString(fields[Field.Xid]), Does.Contain("sensor")); + Assert.That( + fields[Field.DocumentKind].TryGetValue(out WoTDocumentKindEnum kind), Is.True, + "DocumentKind must be populated from the resource kind."); + Assert.That(kind, Is.EqualTo(WoTDocumentKindEnum.ThingDescription)); + Assert.That( + fields[Field.Outcome].TryGetValue(out WoTOutcomeEnum _), Is.True, + "Outcome must be populated for a resource lifecycle event."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ValidationFailureEventDeliversValidationOutcomeThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + // The selective converter fails conversion for ids containing 'bad', + // which the coordinator surfaces as a validation failure event. + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "bad-thing", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("bad-thing") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + var validationFailureType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTValidationFailureEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == validationFailureType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The validation failure event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("bad-thing")); + Assert.That( + fields[Field.ValidationOutcome].TryGetValue(out ExtensionObject outcomeEo), Is.True, + "The ValidationOutcome structure field must be populated."); + Assert.That(outcomeEo.TryGetValue(out WoTValidationOutcomeDataType? outcome), Is.True); + Assert.That(outcome!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + private static class Field + { + public const int EventType = 0; + public const int Xid = 1; + public const int ResourceId = 2; + public const int VersionId = 3; + public const int DocumentKind = 4; + public const int Generation = 5; + public const int Phase = 6; + public const int Outcome = 7; + public const int ValidationOutcome = 8; + public const int LoadState = 9; + public const int FailedNodeId = 10; + public const int Reason = 11; + public const int BindingUri = 12; + public const int Summary = 13; + public const int RequestId = 14; + } + + private EventFilter BuildWotEventFilter() + { + ushort v2 = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex( + WotConModel.Namespaces.WotCon); + + SimpleAttributeOperand Wot(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = UaObjectTypeIds.BaseEventType, + BrowsePath = [new QualifiedName(name, v2)] + }; + + SimpleAttributeOperand Base(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = UaObjectTypeIds.BaseEventType, + BrowsePath = [QualifiedName.From(name)] + }; + + return new EventFilter + { + SelectClauses = + [ + Base(UaBrowseNames.EventType), // 0 + Wot(WotConModel.BrowseNames.Xid), // 1 + Wot(WotConModel.BrowseNames.ResourceId), // 2 + Wot(WotConModel.BrowseNames.VersionId), // 3 + Wot(WotConModel.BrowseNames.DocumentKind), // 4 + Wot(WotConModel.BrowseNames.Generation), // 5 + Wot(WotConModel.BrowseNames.Phase), // 6 + Wot(WotConModel.BrowseNames.Outcome), // 7 + Wot(WotConModel.BrowseNames.ValidationOutcome), // 8 + Wot(WotConModel.BrowseNames.LoadState), // 9 + Wot(WotConModel.BrowseNames.FailedNodeId), // 10 + Wot(WotConModel.BrowseNames.Reason), // 11 + Wot(WotConModel.BrowseNames.BindingUri), // 12 + Wot(WotConModel.BrowseNames.Summary), // 13 + Wot(WotConModel.BrowseNames.RequestId) // 14 + ], + WhereClause = new ContentFilter() + }; + } + + private async Task CreateEventSubscriptionAsync( + ServerTestServices services, NodeId sourceNodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscription = await services + .CreateSubscriptionAsync(requestHeader, 100, 1200, 20, 0, true, 0) + .ConfigureAwait(false); + uint subscriptionId = subscription.SubscriptionId; + + ArrayOf items = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = sourceNodeId, + AttributeId = Attributes.EventNotifier + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = ClientHandle, + SamplingInterval = 0, + QueueSize = 100, + DiscardOldest = true, + Filter = new ExtensionObject(BuildWotEventFilter()) + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse created = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Neither, items) + .ConfigureAwait(false); + Assert.That(created.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good), + "The event monitored item must be created on the WoTRegistry notifier."); + return subscriptionId; + } + + private async Task CollectEventAsync( + ServerTestServices services, uint subscriptionId, Func predicate) + { + ArrayOf acks = default; + for (int attempt = 0; attempt < 40; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acks, timeoutCts.Token).ConfigureAwait(false); + acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + ArrayOf notifications = message.NotificationData; + for (int n = 0; n < notifications.Count; n++) + { + if (!notifications[n].TryGetValue(out EventNotificationList? events)) + { + continue; + } + for (int i = 0; i < events.Events.Count; i++) + { + EventFieldList efl = events.Events[i]; + if (efl.ClientHandle == ClientHandle && predicate(efl)) + { + return efl; + } + } + } + } + } + return null; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf ids = [subscriptionId]; + await services.DeleteSubscriptionsAsync(requestHeader, ids).ConfigureAwait(false); + } + + private static NodeId EventTypeOf(EventFieldList efl) + { + return efl.EventFields[Field.EventType].TryGetValue(out NodeId n) ? n : NodeId.Null; + } + + private static string AsString(Variant variant) + { + return variant.TryGetValue(out string s) ? s : string.Empty; + } + + private static uint AsUInt32(Variant variant) + { + return variant.TryGetValue(out uint u) ? u : 0u; + } + + private const uint ClientHandle = 77; + + /// + /// A converter that emits a minimal valid projection for a resource, but + /// fails conversion for any resource id containing "bad" so a validation + /// failure event can be exercised deterministically. + /// + private sealed class SelectiveConverter : IWotDocumentConverter + { + private const string ModelUri = "urn:wot:events:model"; + + public static byte[] ValidTd(string id) + { + return Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:" + + id + + "\",\"title\":\"" + + id + + "\"}"); + } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(Convert(resource)); + } + + private static WotConversionOutput Convert(WotResource resource) + { + if (resource.ResourceId.Contains("bad", StringComparison.Ordinal)) + { + return WotConversionOutput.Failure( + $"Injected conversion failure for '{resource.ResourceId}'."); + } + + string ns = ModelUri + "/" + resource.ResourceId; + string xml = $""" + + + {ns} + + + Root + + i=58 + i=85 + + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs new file mode 100644 index 0000000000..e0d85771f4 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs @@ -0,0 +1,895 @@ +/* ======================================================================== + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.WotCon; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; +using UaObjectIds = global::Opc.Ua.ObjectIds; +using UaReferenceTypeIds = global::Opc.Ua.ReferenceTypeIds; + +#nullable disable warnings + +namespace Opc.Ua.WotCon.Tests.RuntimeNodeSet +{ + /// + /// End-to-end lifecycle test for the WoT Connectivity V2 registry hosted on a + /// real running . It registers a Thing + /// Description, materializes it as a shadow-reloadable runtime projection, + /// creates a real subscription and monitored item on the projected value, + /// registers a compatible new version, refreshes into a new generation, and + /// verifies that new Read/Browse observe the new generation while the existing + /// monitored item is kept alive on the retained generation until the + /// subscription is deleted and the retired projection is cleaned up. + /// + [TestFixture] + [Category("NodeManagerLifecycle")] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryLifecycleTests + { + private const double kMaxAge = 10000; + private const string kModelNamespaceUri = "urn:wot:e2e:sensor"; + private const uint kRootNodeId = 5000; + private const uint kValueNodeId = 5001; + private const uint kGenChildBaseNodeId = 5100; + private const string kValueBrowseName = "Value"; + + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + private WotRegistryServerOptions m_options = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryLifecycleTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + // Host the WoT registry NodeManager on the running server with a + // deterministic converter so the projected value node is predictable. + m_options = new WotRegistryServerOptions + { + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SensorConverter()); + var factory = new WotRegistryNodeManagerFactory(m_options, m_registry, m_coordinator); + await m_server.NodeManagerLifecycle.AddAsync(factory).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_requestHeader is not null) + { + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RegisterMaterializeSubscribeRefreshAndRetireAsync() + { + IServerInternal server = m_server.CurrentInstance; + + // 1. Register and materialize the first generation of the Thing Description. + await UpsertSensorAsync("sensor", generation: 1).ConfigureAwait(false); + WotRefreshResult first = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(first.Results.Any(r => r.LoadState == WoTLoadStateEnum.Active), Is.True, + "The registered Thing Description must materialize into an active projection."); + + // The browseable registry projection exposes the group and resource. + await AssertRegistryProjectionAsync(server).ConfigureAwait(false); + + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + Assert.That(ns, Is.GreaterThan(0), "The projected model namespace must be registered."); + var valueNodeId = new NodeId(kValueNodeId, ns); + var rootNodeId = new NodeId(kRootNodeId, ns); + var gen1ChildNodeId = new NodeId(kGenChildBaseNodeId + 1u, ns); + var gen2ChildNodeId = new NodeId(kGenChildBaseNodeId + 2u, ns); + + DataValue value1 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value1.StatusCode, Is.EqualTo(StatusCodes.Good), + "The projected value node must be materialized and readable."); + DataValue gen1Read = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That(gen1Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "The first generation's node must be present after materialization."); + + // 2. Create a subscription and monitored item on the projected value. + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) + .ConfigureAwait(false); + ArrayOf acks = default; + (DataValue? initial, acks) = await PublishForDataChangeAsync( + services, subscriptionId, acks, clientHandle: 1).ConfigureAwait(false); + Assert.That(initial, Is.Not.Null, + "The monitored item must deliver an initial data-change notification."); + + try + { + // 3. Register a compatible new version and refresh into a new generation. + await UpsertSensorAsync("sensor", generation: 2).ConfigureAwait(false); + WotRefreshResult second = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(second.NewGeneration, Is.GreaterThan(first.NewGeneration), + "A compatible new version must advance the refresh generation."); + + // 4. New Read/Browse observe the new generation: new service requests + // route to the replacement generation (which exposes the Gen2 node and + // no longer the Gen1 node), while the value node persists across both. + DataValue value2 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value2.StatusCode, Is.EqualTo(StatusCodes.Good)); + DataValue gen2Read = await ReadValueAsync(gen2ChildNodeId).ConfigureAwait(false); + Assert.That(gen2Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "A Read after the refresh must observe the new generation's node."); + DataValue gen1AfterSwitch = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That( + gen1AfterSwitch.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "New requests must no longer resolve the retired generation's node."); + + BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false); + ArrayOf references = rootBrowse.Results[0].References; + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(2), ns))), + Is.True, "Browse must observe the new generation's child."); + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(1), ns))), + Is.False, "Browse must no longer observe the retired generation's child."); + + // 5. The existing monitored item remains alive across the switch: the + // subscription still services publishes without invalidating the item. + (_, acks) = await PublishKeepAliveAsync(services, subscriptionId, acks) + .ConfigureAwait(false); + } + finally + { + // 6. Delete the subscription, releasing the monitored item. + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // 7. Remove the resource and refresh: the retired projection is cleaned up. + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "sensor") + .ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + DataValue removed = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That( + removed.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "The retired projection's nodes must be cleaned up after the resource is removed."); + } + + private async Task AssertRegistryProjectionAsync(IServerInternal server) + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + ushort v2Ns = (ushort)server.NamespaceUris.GetIndex(WotConModel.Namespaces.WotCon); + var groupNodeId = new NodeId( + "WoTRegistry/groups/" + WotRegistryGroups.ThingDescriptions, v2Ns); + var resourceNodeId = new NodeId( + $"WoTRegistry/groups/{WotRegistryGroups.ThingDescriptions}/resources/sensor", v2Ns); + + bool groupVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(registryNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == groupNodeId); + }).ConfigureAwait(false); + Assert.That(groupVisible, Is.True, "WoTRegistry must expose the Thing Description group."); + + bool resourceVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(groupNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == resourceNodeId); + }).ConfigureAwait(false); + Assert.That(resourceVisible, Is.True, + "The group must expose the registered resource document."); + } + + [Test] + public async Task CrudMethodsAndFileUploadDriveTheRegistryAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // 1. CreateGroup via the xRegistry CreateGroup Method on WoTRegistry. + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("sensors")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindGroup("sensors"), Is.Not.Null); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + // 2. GetOrCreateResource with RequestFileOpen returns a write FileHandle. + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(true)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint fileHandle = createResource.OutputArguments[2].GetUInt32(); + Assert.That(fileHandle, Is.Not.Zero, + "RequestFileOpen must return a non-zero write FileHandle."); + + byte[] td = Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:thing1\",\"title\":\"thing1\"}"); + + // 3. Write the document body through the inherited FileType and commit on Close. + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, writeId, + new Variant(fileHandle), new Variant(ByteString.From(td))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.Good)); + + CallMethodResult close = await CallAsync( + resourceNodeId, closeId, new Variant(fileHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + + WotResource stored = m_registry.Current.FindResource("sensors", "thing1"); + Assert.That(stored?.DefaultVersion, Is.Not.Null, + "Closing the write handle must commit the buffered document as a version."); + + // 4. Validate the stored document. + NodeId validateId = await FindChildAsync(resourceNodeId, "Validate").ConfigureAwait(false); + CallMethodResult validate = await CallAsync(resourceNodeId, validateId) + .ConfigureAwait(false); + Assert.That(validate.StatusCode, Is.EqualTo(StatusCodes.Good)); + object outcomeBoxed = validate.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + Assert.That(outcomeBoxed, Is.InstanceOf()); + Assert.That(((ExtensionObject)outcomeBoxed).TryGetValue( + out WoTValidationOutcomeDataType outcome), Is.True); + Assert.That(outcome.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Success)); + + // 5. SetEnabled(false) through the document Method. + NodeId setEnabledId = await FindChildAsync(resourceNodeId, "SetEnabled") + .ConfigureAwait(false); + CallMethodResult setEnabled = await CallAsync( + resourceNodeId, setEnabledId, + new Variant(false), new Variant(0u)).ConfigureAwait(false); + Assert.That(setEnabled.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1")!.Enabled, Is.False); + + // 6. Delete the resource through the xRegistry Delete Method. + NodeId deleteId = await FindChildAsync(resourceNodeId, "Delete").ConfigureAwait(false); + CallMethodResult delete = await CallAsync( + resourceNodeId, deleteId, new Variant(0u)).ConfigureAwait(false); + Assert.That(delete.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1"), Is.Null); + } + + [Test] + public async Task FileWriteRequiresConfiguredSecureChannelWhileReadMayUseNoneAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, + server.NamespaceUris); + + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, + createGroupId, + new Variant("secure-files")).ConfigureAwait(false); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId createResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, + createResourceId, + new Variant("thing1"), + new Variant(string.Empty), + new Variant(true)).ConfigureAwait(false); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint writeHandle = createResource.OutputArguments[2].GetUInt32(); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, + writeId, + new Variant(writeHandle), + new Variant(ByteString.From(new byte[] { 1, 2, 3 }))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult writeClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(writeHandle)).ConfigureAwait(false); + Assert.That(writeClose.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + NodeId openId = await FindChildAsync(resourceNodeId, "Open").ConfigureAwait(false); + CallMethodResult authorizedWriteOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(authorizedWriteOpen.StatusCode, Is.EqualTo(StatusCodes.Good), + "A denied close must discard and release the prior writer handle."); + uint authorizedWriteHandle = authorizedWriteOpen.OutputArguments[0].GetUInt32(); + CallMethodResult authorizedWriteClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(authorizedWriteHandle)).ConfigureAwait(false); + Assert.That(authorizedWriteClose.StatusCode, Is.EqualTo(StatusCodes.Good)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + CallMethodResult writeOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(writeOpen.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + CallMethodResult readOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)1)).ConfigureAwait(false); + Assert.That(readOpen.StatusCode, Is.EqualTo(StatusCodes.Good)); + uint readHandle = readOpen.OutputArguments[0].GetUInt32(); + + CallMethodResult close = await CallAsync( + resourceNodeId, + closeId, + new Variant(readHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public async Task LabelsAddUpdateRemoveViaRealNodeManagerAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // Registry-level Labels. + NodeId registryLabelsId = await FindChildAsync(registryNodeId, "Labels") + .ConfigureAwait(false); + NodeId registryAddId = await FindChildAsync(registryLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId registryRemoveId = await FindChildAsync(registryLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addRegistry = await CallAsync( + registryLabelsId, registryAddId, + new Variant("environment"), new Variant("production"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + + NodeId envNodeId = await FindChildAsync(registryLabelsId, "environment") + .ConfigureAwait(false); + DataValue envValue = await ReadValueAsync(envNodeId).ConfigureAwait(false); + Assert.That(envValue.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(envValue.GetValue(null), Is.EqualTo("production")); + + // Group-level Labels. + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("labelgroup")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId groupLabelsId = await FindChildAsync(groupNodeId, "Labels").ConfigureAwait(false); + NodeId groupAddId = await FindChildAsync(groupLabelsId, "AddAttribute") + .ConfigureAwait(false); + + CallMethodResult addGroupLabel = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-iot"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addGroupLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId ownerNodeId = await FindChildAsync(groupLabelsId, "owner").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot")); + + // Epoch mismatch is rejected with Bad_InvalidState and makes no change. + NodeId groupEpochId = await FindChildAsync(groupNodeId, "Epoch").ConfigureAwait(false); + uint groupEpoch = (await ReadValueAsync(groupEpochId).ConfigureAwait(false)) + .GetValue(0); + CallMethodResult mismatchedGroup = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-other"), new Variant(groupEpoch + 999)) + .ConfigureAwait(false); + Assert.That(mismatchedGroup.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot"), "A rejected epoch mismatch must not change the label value."); + + // A key colliding with a fixed Labels container member is rejected. + CallMethodResult collision = await CallAsync( + groupLabelsId, groupAddId, + new Variant("AddAttribute"), new Variant("x"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(collision.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + + // A key with a path-separator character is rejected. + CallMethodResult invalidKey = await CallAsync( + groupLabelsId, groupAddId, + new Variant("a/b"), new Variant("x"), new Variant(0u)).ConfigureAwait(false); + Assert.That(invalidKey.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + + // Resource-level Labels. + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(false)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId resourceLabelsId = await FindChildAsync(resourceNodeId, "Labels") + .ConfigureAwait(false); + NodeId resourceAddId = await FindChildAsync(resourceLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId resourceRemoveId = await FindChildAsync(resourceLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addResourceLabel = await CallAsync( + resourceLabelsId, resourceAddId, + new Variant("site"), new Variant("seattle"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId siteNodeId = await FindChildAsync(resourceLabelsId, "site").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(siteNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("seattle")); + + // Remove the resource label; it must disappear from Browse. + CallMethodResult removeResourceLabel = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("site"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse afterRemove = await BrowseAsync(resourceLabelsId).ConfigureAwait(false); + Assert.That( + afterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "site", StringComparison.Ordinal)), + Is.False); + + // Removing an unknown label fails with a precise StatusCode. + CallMethodResult removeUnknown = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("missing"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeUnknown.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + + // Registry-level remove. + CallMethodResult removeRegistry = await CallAsync( + registryLabelsId, registryRemoveId, + new Variant("environment"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse registryAfterRemove = await BrowseAsync(registryLabelsId) + .ConfigureAwait(false); + Assert.That( + registryAfterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "environment", StringComparison.Ordinal)), + Is.False); + } + + private async Task FindChildAsync(NodeId parent, string browseName) + { + BrowseResponse browse = await BrowseAsync(parent).ConfigureAwait(false); + foreach (ReferenceDescription reference in browse.Results[0].References) + { + if (string.Equals(reference.BrowseName.Name, browseName, StringComparison.Ordinal)) + { + return ExpandedNodeId.ToNodeId( + reference.NodeId, m_server.CurrentInstance.NamespaceUris); + } + } + Assert.Fail($"Child '{browseName}' was not found under {parent}."); + return NodeId.Null; + } + + private async Task CallAsync( + NodeId objectId, NodeId methodId, params Variant[] inputs) + { + ArrayOf methods = + [ + new CallMethodRequest + { + ObjectId = objectId, + MethodId = methodId, + InputArguments = inputs + } + ]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CallResponse response = await m_server + .CallAsync(m_secureChannelContext, requestHeader, methods, RequestLifetime.None) + .ConfigureAwait(false); + return response.Results[0]; + } + + private static async Task WaitForConditionAsync(Func> condition) + { + for (int attempt = 0; attempt < 50; attempt++) + { + if (await condition().ConfigureAwait(false)) + { + return true; + } + await Task.Delay(100).ConfigureAwait(false); + } + return false; + } + + private async Task UpsertSensorAsync(string resourceId, int generation) + { + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SensorConverter.BuildContent(generation) + }).ConfigureAwait(false); + } + + private static string GenChildBrowseName(int generation) + { + return "Gen" + generation.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Emits a NodeSet2 whose model namespace is fixed and whose value node + /// carries the generation number parsed from the document, plus a + /// generation-specific child so a Browse can distinguish generations. + /// + private sealed class SensorConverter : IWotDocumentConverter + { + public static byte[] BuildContent(int generation) + { + return Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"" + + kModelNamespaceUri + + "\"," + + "\"title\":\"sensor\",\"gen\":" + + generation.ToString(CultureInfo.InvariantCulture) + + "}"); + } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(Convert(content)); + } + + private static WotConversionOutput Convert(ReadOnlyMemory content) + { + int generation = ParseGeneration(content.Span); + uint childId = kGenChildBaseNodeId + (uint)generation; + string childName = "Gen" + generation.ToString(CultureInfo.InvariantCulture); + string xml = $""" + + + + {kModelNamespaceUri} + + + + + + Sensor + + i=58 + ns=1;i={kValueNodeId} + ns=1;i={childId} + i=85 + + + + {kValueBrowseName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + {childName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + + private static int ParseGeneration(ReadOnlySpan content) + { + try + { + var reader = new System.Text.Json.Utf8JsonReader(content); + while (reader.Read()) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.PropertyName && + reader.GetString() == "gen" && + reader.Read()) + { + return reader.GetInt32(); + } + } + } + catch (System.Text.Json.JsonException) + { + // fall through + } + return 1; + } + } + + private async Task ReadValueAsync(NodeId nodeId, uint attributeId = Attributes.Value) + { + ArrayOf readIds = + [new ReadValueId { NodeId = nodeId, AttributeId = attributeId }]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ReadResponse response = await m_server.ReadAsync( + m_secureChannelContext, requestHeader, kMaxAge, + TimestampsToReturn.Neither, readIds, RequestLifetime.None).ConfigureAwait(false); + return response.Results[0]; + } + + private async Task BrowseAsync(NodeId nodeId) + { + var services = new ServerTestServices(m_server, m_secureChannelContext); + var template = new BrowseDescription + { + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = UaReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + ArrayOf nodesToBrowse = + ServerFixtureUtils.CreateBrowseDescriptionCollectionFromNodeId([nodeId], template); + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + return await services + .BrowseAsync(requestHeader, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse) + .ConfigureAwait(false); + } + + private async Task CreateSubscriptionWithMonitoredItemAsync( + ServerTestServices services, NodeId nodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscriptionResponse = await services + .CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0).ConfigureAwait(false); + uint subscriptionId = subscriptionResponse.SubscriptionId; + + ArrayOf monitoredItems = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, SamplingInterval = 0, QueueSize = 1, DiscardOldest = true + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse createItemsResponse = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems) + .ConfigureAwait(false); + Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + return subscriptionId; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf subscriptionIds = [subscriptionId]; + DeleteSubscriptionsResponse response = await services + .DeleteSubscriptionsAsync(requestHeader, subscriptionIds).ConfigureAwait(false); + Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); + } + + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements, uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + Assert.That(value, Is.Not.Null, + $"No data-change notification for client handle {clientHandle} arrived."); + return (value, acknowledgements); + } + + private async Task<(bool Alive, ArrayOf Acknowledgements)> + PublishKeepAliveAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId), + "The subscription and its monitored item must stay alive across the shadow reload."); + var acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + return (true, acks); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs b/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs new file mode 100644 index 0000000000..879c468e31 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs @@ -0,0 +1,253 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Support +{ + /// + /// A worked sample showing how a third party contributes a replaceable + /// protocol binder as pure code-behind. The fictitious mem protocol + /// binds property affordances to an in-process key/value store, demonstrating + /// the full extension surface: identity, capability, deterministic + /// identification, a planner and an executor with a live channel. Register it + /// with builder.AddWotBinder(new MemoryWotBinder()) and + /// builder.AddWotBindingExecutor(new MemoryWotBindingExecutor(store)). + /// + public sealed class MemoryWotBinder : WotProtocolBinderBase + { + /// + /// The sample binding vocabulary URI. + /// + public const string BindingUri = "urn:example:wot:mem"; + + private static readonly string[] s_schemes = ["mem"]; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", BindingUri, "Sample In-Memory Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "Sample In-Memory Binding", + new WotBindingSource("urn:example:wot:mem", "1.0", WotBindingMaturity.UnofficialDraft, + note: "A sample custom binding for documentation and tests."), + [ + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty + ], + ["application/json", "text/plain"], + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + { + return MatchStandard(form, context, "memv:"); + } + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href) || + !TryParseUri(href, out Uri uri) || + !string.Equals(uri.Scheme, "mem", StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid mem:// URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported([.. diagnostics]); + } + + string key = uri.AbsolutePath.Trim('/'); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(key); + + ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + [], Capability.IsExecutable)); + } + + return entries.Count == 0 + ? WotBindingCompilation.Unsupported([.. diagnostics]) + : WotBindingCompilation.Supported(entries.ToImmutable(), [.. diagnostics]); + } + } + + /// + /// The in-process key/value store the sample binding reads and writes. + /// + public sealed class MemoryWotStore + { + /// + /// Gets the value stored under a key. + /// + public DataValue Get(string key) + { + return m_values.TryGetValue(key, out DataValue value) ? value : new DataValue(Variant.Null); + } + + /// + /// Sets the value stored under a key. + /// + public void Set(string key, DataValue value) + { + m_values[key] = value; + } + + private readonly ConcurrentDictionary m_values = + new(StringComparer.Ordinal); + } + + /// + /// The executor for the sample in-memory binding. + /// + public sealed class MemoryWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new sample executor over the supplied store. + /// + public MemoryWotBindingExecutor(MemoryWotStore store) + { + m_store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", MemoryWotBinder.BindingUri, "Sample In-Memory Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The channel is owned by the caller, who disposes it via DisposeAsync.")] + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + IWotBindingChannel channel = new MemoryWotBindingChannel(m_store, form); + return new ValueTask(channel); + } + + private readonly MemoryWotStore m_store; + } + + /// + /// The live channel for the sample in-memory binding. + /// + internal sealed class MemoryWotBindingChannel : IWotBindingChannel + { + public MemoryWotBindingChannel(MemoryWotStore store, WotCompiledForm form) + { + m_store = store; + Form = form; + m_key = form.Addressing.Target; + } + + public WotCompiledForm Form { get; } + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(new WotReadResult(StatusCodes.Good, m_store.Get(m_key))); + } + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + m_store.Set(m_key, value); + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotInvokeResult( + StatusCodes.BadNotSupported, null, "The sample binding has no actions.")); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription(Form, token => + { + onNotification(new WotNotification(m_store.Get(m_key))); + return new ValueTask(true); + }, TimeSpan.FromMilliseconds(200)); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return ObserveAsync(onEvent, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return default; + } + + private readonly MemoryWotStore m_store; + private readonly string m_key; + } +} From 36c3389ffb806550dd2d43391a061d69ffac8d3a Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 19:16:02 +0200 Subject: [PATCH 3/7] Address WoT materialization review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../OpcUaWotRegistryServerBuilderExtensions.cs | 4 ++++ .../Materialization/WotRefreshArguments.cs | 4 +++- ...otRegistryServerBuilderExtensionsCoverageTests.cs | 11 +++++++++++ .../WotDependencyGraphExtendedTests.cs | 2 +- .../WotMaterializationCoordinatorExtendedTests.cs | 3 ++- .../Materialization/WotRefreshArgumentsTests.cs | 12 ++++++++++++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs index fdc55dad6a..93f9a2bc9c 100644 --- a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs +++ b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs @@ -93,6 +93,10 @@ public static IOpcUaBuilder AddWotRegistryServer( this IOpcUaBuilder builder, IConfiguration configuration) { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } if (configuration is null) { throw new ArgumentNullException(nameof(configuration)); diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs index 12cf3f7940..7ea847076e 100644 --- a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs @@ -130,7 +130,9 @@ private static ServiceResult TryDecodeSelection( { if (element is null) { - continue; + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Selection argument must be an array of WoTResourceSelectorDataType."); } ServiceResult status = TryCoerce( element, context, out WoTResourceSelectorDataType? selector); diff --git a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs index c77e5ce943..978bb8bcba 100644 --- a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs @@ -140,6 +140,17 @@ public void AddWotRegistryServerWithNullConfigurationThrowsArgumentNull() Throws.ArgumentNullException); } + [Test] + public void AddWotRegistryServerWithNullBuilderAndConfigurationThrowsArgumentNull() + { + IConfiguration configuration = new ConfigurationBuilder().Build(); + + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, configuration), + Throws.ArgumentNullException); + } + [Test] public void AddWotRegistryServerWithConfigurationSectionBindsOptions() { diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs index 189e27d3ab..d1c2a2144d 100644 --- a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs @@ -89,7 +89,7 @@ await service.UpsertResourceAsync(new WotUpsertResourceRequest } [Test] - public async Task ResolveWithNullSnapshotReturnsNull() + public void ResolveWithNullSnapshotReturnsNull() { WotResource? result = WotDependencyGraph.Resolve(null!, "urn:some"); Assert.That(result, Is.Null); diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs index a9d1b77ba7..2d9ce126a8 100644 --- a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs @@ -167,6 +167,7 @@ public async Task SelectionFilterLimitsResultsToSelectedResources() Assert.That(result.Results.All(r => r.ResourceId == "td-a"), Is.True, "Filtered refresh must only return results for selected resources."); + Assert.That(result.Results.Single().Xid, Is.EqualTo(tdAXid)); } [Test] @@ -341,7 +342,7 @@ public RecordingResolver(Func resolve) } [Test] - public async Task BindingCapabilitiesIsNonNullEvenWithNoBinders() + public void BindingCapabilitiesIsNonNullEvenWithNoBinders() { Assert.That(m_coordinator.BindingCapabilities, Is.Not.Null); Assert.That(m_coordinator.BindingCapabilities, Is.Empty); diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs index d2088f4c5e..cd80b0c9f5 100644 --- a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs @@ -132,6 +132,18 @@ public void RejectsSelectionOfWrongElementType() Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); } + [Test] + public void RejectsNullElementInSelectionArray() + { + ArrayOf selection = [null!]; + ArrayOf input = Args(Variant.From(selection)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + [Test] public void RejectsOptionsOfWrongType() { From 7b00194ebadd242956176fed89aa14023f952a7d Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 16:40:31 +0200 Subject: [PATCH 4/7] Treat the registry management minimum security mode as a floor WotRegistryNodeManager.CheckManagementAccess carried the same equality comparison as the connectivity node manager, so lowering MinimumSecurityMode to Sign denied clients on the stronger SignAndEncrypt channel. Compare with less-than so any channel at or above the configured mode is accepted and Invalid is still rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs index a959314583..c62502f4f6 100644 --- a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs @@ -487,7 +487,10 @@ internal ServiceResult CheckManagementAccess(ISystemContext context, string oper MessageSecurityMode securityMode = operationContext.ChannelContext? .EndpointDescription?.SecurityMode ?? MessageSecurityMode.None; - if (securityMode != policy.MinimumSecurityMode) + // MinimumSecurityMode is a floor, not an exact match: MessageSecurityMode is ordered by + // strength (Invalid < None < Sign < SignAndEncrypt), so a channel at or above the + // configured mode is accepted and Invalid is always rejected. + if (securityMode < policy.MinimumSecurityMode) { m_logger.ManagementCallDeniedSecurityMode(operation, securityMode); return StatusCodes.BadUserAccessDenied; From 7f5cca07217cac548630b616e7e85274bf25ce6c Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 3 Aug 2026 17:02:59 +0200 Subject: [PATCH 5/7] Keep WoT materialization dry runs side-effect free Dry-run refreshes computed and reported a candidate result but still executed the retirement path and advanced the coordinator generation. That let a preview deactivate binders, remove host projections, drop tracked closures, and shift later optimistic-concurrency checks. Compute the candidate generation without committing it, apply expected-generation checks to the committed refresh generation, and make retirement reconciliation report what would be retired without touching binders, host registrations, or tracked closure state during dry runs. Add regression coverage for retirement previews and repeated dry-run generation checks before a real refresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../WotMaterializationCoordinator.cs | 93 +++++++++++++--- .../WotMaterializationTypes.cs | 2 +- .../WotMaterializationCoordinatorTests.cs | 103 ++++++++++++++++++ 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs index 03c63cbe47..34f135f478 100644 --- a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs @@ -112,9 +112,9 @@ public async ValueTask RefreshAsync( WotRegistrySnapshot snapshot = m_registry.Current; if (request.ExpectedGeneration != 0 && - request.ExpectedGeneration != (uint)snapshot.Generation) + request.ExpectedGeneration != m_generation) { - return RejectedResult(request, snapshot, start); + return RejectedResult(request, start); } bool dryRun = request.Options?.DryRun ?? false; @@ -132,7 +132,7 @@ public async ValueTask RefreshAsync( var targetKeys = new HashSet( closures.Select(c => c.Key), StringComparer.Ordinal); - uint newGeneration = ++m_generation; + uint newGeneration = m_generation + 1; ImmutableArray.Builder results = ImmutableArray.CreateBuilder(); var projections = new List(); @@ -144,8 +144,12 @@ public async ValueTask RefreshAsync( // Retire tracked closures no longer desired (deleted / disabled / // membership changed) after their monitored items drain. - retired += await ReconcileRetirementsAsync( - targetKeys, cancellationToken).ConfigureAwait(false); + (int retiredCount, ImmutableArray retiredResults) = + await ReconcileRetirementsAsync( + targetKeys, newGeneration, dryRun, cancellationToken).ConfigureAwait(false); + retired += retiredCount; + skipped += retiredResults.Length; + results.AddRange(retiredResults); foreach (WotDependencyClosure closure in closures) { @@ -191,6 +195,10 @@ public async ValueTask RefreshAsync( await m_registry.ApplyProjectionResultsAsync( projections, cancellationToken).ConfigureAwait(false); } + if (!dryRun) + { + m_generation = newGeneration; + } WoTOutcomeEnum overall = failed > 0 ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) @@ -472,11 +480,12 @@ await ResolveDependencyModelsAsync( Outcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success, Phase = WoTPhaseEnum.Projection, LoadState = member.LoadState, - Generation = 0, + Generation = generation, MaterializedNodeCount = (uint)(perMemberNodeCount.TryGetValue( member.Xid, out int c) ? c : 0), ContentDigest = DigestOf(member), - Message = "Dry run; no projection committed." + Message = "Dry run; no projection committed. Candidate generation " + + generation.ToString(CultureInfo.InvariantCulture) + "." }); } return new ClosureOutcome(results.ToImmutable(), projections); @@ -546,6 +555,15 @@ await ResolveDependencyModelsAsync( AggregateDigest = aggregateDigest, Generation = generation, MemberXids = [.. members.Select(m => m.Xid)], + Members = [.. members.Select(m => new ClosureMemberState + { + Xid = m.Xid, + GroupId = m.GroupId, + ResourceId = m.ResourceId, + VersionId = m.DefaultVersionId ?? string.Empty, + Kind = m.Kind, + ContentDigest = DigestOf(m) + })], ModelNamespaceUris = [.. sources.SelectMany(s => s.ModelNamespaceUris)], BindingPlans = [.. bindingPlans] }; @@ -604,10 +622,15 @@ await ResolveDependencyModelsAsync( return new ClosureOutcome(results.ToImmutable(), projections); } - private async ValueTask ReconcileRetirementsAsync( + private async ValueTask<(int Retired, ImmutableArray Results)> + ReconcileRetirementsAsync( HashSet targetKeys, + uint generation, + bool dryRun, CancellationToken cancellationToken) { + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); int retired = 0; foreach (string key in (List)[.. m_closures.Keys.Where(k => !targetKeys.Contains(k))]) { @@ -615,6 +638,15 @@ private async ValueTask ReconcileRetirementsAsync( { if (state.Handle is not null) { + retired++; + foreach (ClosureMemberState member in state.Members) + { + results.Add(RetiredResult(member, generation)); + } + if (dryRun) + { + continue; + } // Deactivate bindings before retiring the projection. foreach (WotBindingPlan plan in state.BindingPlans) { @@ -623,12 +655,14 @@ await m_binders.DeactivateAsync(plan, cancellationToken) } await m_host.RemoveAsync(state.Handle, cancellationToken) .ConfigureAwait(false); - retired++; } - m_closures.Remove(key); + if (!dryRun) + { + m_closures.Remove(key); + } } } - return retired; + return (retired, dryRun ? results.ToImmutable() : []); } private async ValueTask<(UANodeSet? NodeSet, ExpandedNodeId Root, string? Error)> TryConvertAsync( @@ -1056,6 +1090,27 @@ private static WoTResourceLoadResultDataType UnchangedResult( }; } + private static WoTResourceLoadResultDataType RetiredResult( + ClosureMemberState member, uint generation) + { + return new() + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.VersionId, + Kind = member.Kind, + Outcome = WoTOutcomeEnum.Skipped, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Unloaded, + Generation = generation, + MaterializedNodeCount = 0, + ContentDigest = member.ContentDigest, + Message = "Dry run; projection would be retired at candidate generation " + + generation.ToString(CultureInfo.InvariantCulture) + "." + }; + } + private static WotResourceProjection FailProjection( WotResource resource, string? message, WoTValidationOutcomeDataType? validation = null) { @@ -1177,7 +1232,7 @@ private void RaiseEvent(WotMaterializationEventArgs args) } private WotRefreshResult RejectedResult( - WotRefreshRequest request, WotRegistrySnapshot snapshot, DateTime start) + WotRefreshRequest request, DateTime start) { var summary = new WoTRefreshSummaryDataType { @@ -1189,7 +1244,17 @@ private WotRefreshResult RejectedResult( }; return new WotRefreshResult( summary, [], - (uint)snapshot.Generation); + m_generation); + } + + private sealed class ClosureMemberState + { + public string Xid { get; set; } = string.Empty; + public string GroupId { get; set; } = string.Empty; + public string ResourceId { get; set; } = string.Empty; + public string VersionId { get; set; } = string.Empty; + public WoTDocumentKindEnum Kind { get; set; } + public ByteString ContentDigest { get; set; } = []; } private sealed class ClosureState @@ -1200,6 +1265,8 @@ private sealed class ClosureState public uint Generation { get; set; } public ImmutableArray MemberXids { get; set; } = []; + public ImmutableArray Members { get; set; } = []; + public ImmutableArray ModelNamespaceUris { get; set; } = []; public ImmutableArray BindingPlans { get; set; } diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs index 5676824bed..b47bcd6ce3 100644 --- a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs @@ -54,7 +54,7 @@ public sealed class WotRefreshRequest public WoTRefreshOptionsDataType Options { get; set; } = new WoTRefreshOptionsDataType(); /// - /// Gets or sets the caller's expected registry generation. When non-zero + /// Gets or sets the caller's expected refresh generation. When non-zero /// and it does not match the current generation, the refresh is rejected. /// public uint ExpectedGeneration { get; set; } diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs index d79c146f4d..d1f67a26c2 100644 --- a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs @@ -29,8 +29,10 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; using Opc.Ua.WotCon.Server.Materialization; using Opc.Ua.WotCon.Server.Registry; @@ -339,6 +341,72 @@ public async Task DryRunDoesNotCommit() Assert.That(m_host.AddCount, Is.Zero, "A dry run must not project."); Assert.That(result.NewGeneration, Is.Zero); + Assert.That(m_coordinator.Generation, Is.Zero); + Assert.That(result.Results.Single().Generation, Is.EqualTo(1u)); + } + + [Test] + public async Task DryRunRetirementDoesNotTearDownProjection() + { + var binders = new RecordingBinderRegistry(); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, binders, documentConverter: m_converter); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + Assert.That(binders.ActivatedPlans, Has.Count.EqualTo(1)); + + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + WotRefreshResult dryRun = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(m_host.RemoveCount, Is.Zero, "A dry-run retirement must not remove the projection."); + Assert.That(binders.DeactivatedPlans, Is.Empty, + "A dry-run retirement must not deactivate active binding plans."); + Assert.That(dryRun.Summary.Retired, Is.EqualTo(1u)); + WoTResourceLoadResultDataType retired = dryRun.Results.Single(); + Assert.That(retired.ResourceId, Is.EqualTo("td-a")); + Assert.That(retired.Outcome, Is.EqualTo(WoTOutcomeEnum.Skipped)); + Assert.That(retired.LoadState, Is.EqualTo(WoTLoadStateEnum.Unloaded)); + Assert.That(retired.Message, Does.Contain("would be retired")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "The committed retirement must still find the tracked closure after the dry run."); + Assert.That(binders.DeactivatedPlans, Has.Count.EqualTo(1)); + } + + [Test] + public async Task DryRunsDoNotAdvanceExpectedGeneration() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + WotRefreshResult committed = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + uint expectedGeneration = committed.NewGeneration; + Assert.That(expectedGeneration, Is.EqualTo(m_coordinator.Generation)); + + for (int i = 0; i < 2; i++) + { + WotRefreshResult dryRun = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(dryRun.NewGeneration, Is.Zero); + Assert.That(m_coordinator.Generation, Is.EqualTo(expectedGeneration)); + } + + WotRefreshResult afterDryRuns = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + ExpectedGeneration = expectedGeneration + }); + + Assert.That(afterDryRuns.Summary.Outcome, Is.Not.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(m_coordinator.Generation, Is.EqualTo(expectedGeneration + 1)); } [Test] @@ -437,5 +505,40 @@ await m_registry.TryCreateResourceAsync( "A content-less placeholder resource must not project."); Assert.That(result.Results, Is.Empty); } + + private sealed class RecordingBinderRegistry : IWotBinderRegistry + { + public List ActivatedPlans { get; } = []; + public List DeactivatedPlans { get; } = []; + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + return new WotBindingPlan(request.ResourceXid, [], [entry], [], []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + ActivatedPlans.Add(plan); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + DeactivatedPlans.Add(plan); + return default; + } + } } } From a238381821aeb8486d653cbb7eceaaaf000059f5 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 4 Aug 2026 12:39:38 +0200 Subject: [PATCH 6/7] Fix WoT materialization shutdown ordering The WoT registry node manager can be asked to delete its address space after the externally-owned materialization coordinator has been disposed. That disposed the coordinator semaphore before DeleteAddressSpaceAsync called RemoveAllAsync, so shutdown reported ObjectDisposedException from the node manager cleanup path. Keep coordinator cleanup available after Dispose when live projections remain, block new refreshes, and release the semaphore once the deferred cleanup has drained. Also order the direct integration-test teardown through the server fixture so the node manager cleanup runs before test-owned services are released, and add a regression test for shutting down with a live materialized projection after coordinator disposal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../WotMaterializationCoordinator.cs | 339 +++++++++++------- .../WotRegistryEventIntegrationTests.cs | 8 +- .../WotRegistryLifecycleTests.cs | 48 ++- 3 files changed, 252 insertions(+), 143 deletions(-) diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs index 34f135f478..dde3cdf5b6 100644 --- a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs @@ -105,136 +105,148 @@ public async ValueTask RefreshAsync( throw new ArgumentNullException(nameof(request)); } - await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); - try + if (!TryBeginOperation(allowDisposed: false)) { - DateTime start = DateTime.UtcNow; - WotRegistrySnapshot snapshot = m_registry.Current; - - if (request.ExpectedGeneration != 0 && - request.ExpectedGeneration != m_generation) - { - return RejectedResult(request, start); - } + throw new ObjectDisposedException(nameof(WotMaterializationCoordinator)); + } - bool dryRun = request.Options?.DryRun ?? false; - bool force = request.Options?.Force ?? false; - bool strict = StrictBindings; - HashSet selectedXids = ResolveSelection(snapshot, request.Selection); - - var enabled = snapshot.AllResources() - .Where(r => r.Enabled && r.DefaultVersion is not null) - .ToList(); - ImmutableArray closures = - WotDependencyGraph.BuildClosures( - snapshot, enabled, m_converterOptions.MaxJsonDepth); - - var targetKeys = new HashSet( - closures.Select(c => c.Key), StringComparer.Ordinal); - - uint newGeneration = m_generation + 1; - ImmutableArray.Builder results = - ImmutableArray.CreateBuilder(); - var projections = new List(); - int succeeded = 0; - int unchanged = 0; - int failed = 0; - int skipped = 0; - int retired = 0; - - // Retire tracked closures no longer desired (deleted / disabled / - // membership changed) after their monitored items drain. - (int retiredCount, ImmutableArray retiredResults) = - await ReconcileRetirementsAsync( - targetKeys, newGeneration, dryRun, cancellationToken).ConfigureAwait(false); - retired += retiredCount; - skipped += retiredResults.Length; - results.AddRange(retiredResults); - - foreach (WotDependencyClosure closure in closures) + try + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - cancellationToken.ThrowIfCancellationRequested(); - bool inScope = selectedXids.Count == 0 || - closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) || - MembersOf(closure).Any(r => selectedXids.Contains(r.Xid)); + DateTime start = DateTime.UtcNow; + WotRegistrySnapshot snapshot = m_registry.Current; - ClosureOutcome outcome = await ProcessClosureAsync( - snapshot, closure, newGeneration, force && inScope, - dryRun, strict, cancellationToken).ConfigureAwait(false); + if (request.ExpectedGeneration != 0 && + request.ExpectedGeneration != m_generation) + { + return RejectedResult(request, start); + } - foreach (WoTResourceLoadResultDataType result in outcome.Results) + bool dryRun = request.Options?.DryRun ?? false; + bool force = request.Options?.Force ?? false; + bool strict = StrictBindings; + HashSet selectedXids = ResolveSelection(snapshot, request.Selection); + + var enabled = snapshot.AllResources() + .Where(r => r.Enabled && r.DefaultVersion is not null) + .ToList(); + ImmutableArray closures = + WotDependencyGraph.BuildClosures( + snapshot, enabled, m_converterOptions.MaxJsonDepth); + + var targetKeys = new HashSet( + closures.Select(c => c.Key), StringComparer.Ordinal); + + uint newGeneration = m_generation + 1; + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + var projections = new List(); + int succeeded = 0; + int unchanged = 0; + int failed = 0; + int skipped = 0; + int retired = 0; + + // Retire tracked closures no longer desired (deleted / disabled / + // membership changed) after their monitored items drain. + (int retiredCount, ImmutableArray retiredResults) = + await ReconcileRetirementsAsync( + targetKeys, newGeneration, dryRun, cancellationToken).ConfigureAwait(false); + retired += retiredCount; + skipped += retiredResults.Length; + results.AddRange(retiredResults); + + foreach (WotDependencyClosure closure in closures) { - string resultXid = result.Xid ?? string.Empty; - if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid)) - { - continue; - } - results.Add(result); - switch (result.Outcome) + cancellationToken.ThrowIfCancellationRequested(); + bool inScope = selectedXids.Count == 0 || + closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) || + MembersOf(closure).Any(r => selectedXids.Contains(r.Xid)); + + ClosureOutcome outcome = await ProcessClosureAsync( + snapshot, closure, newGeneration, force && inScope, + dryRun, strict, cancellationToken).ConfigureAwait(false); + + foreach (WoTResourceLoadResultDataType result in outcome.Results) { - case WoTOutcomeEnum.Success: - case WoTOutcomeEnum.Warning: - succeeded++; - break; - case WoTOutcomeEnum.Unchanged: - unchanged++; - break; - case WoTOutcomeEnum.Skipped: - skipped++; - break; - default: - failed++; - break; + string resultXid = result.Xid ?? string.Empty; + if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid)) + { + continue; + } + results.Add(result); + switch (result.Outcome) + { + case WoTOutcomeEnum.Success: + case WoTOutcomeEnum.Warning: + succeeded++; + break; + case WoTOutcomeEnum.Unchanged: + unchanged++; + break; + case WoTOutcomeEnum.Skipped: + skipped++; + break; + default: + failed++; + break; + } } + projections.AddRange(outcome.Projections); } - projections.AddRange(outcome.Projections); - } - if (!dryRun && projections.Count > 0) - { - await m_registry.ApplyProjectionResultsAsync( - projections, cancellationToken).ConfigureAwait(false); - } - if (!dryRun) - { - m_generation = newGeneration; - } + if (!dryRun && projections.Count > 0) + { + await m_registry.ApplyProjectionResultsAsync( + projections, cancellationToken).ConfigureAwait(false); + } + if (!dryRun) + { + m_generation = newGeneration; + } - WoTOutcomeEnum overall = failed > 0 - ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) - : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged); + WoTOutcomeEnum overall = failed > 0 + ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) + : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged); - var summary = new WoTRefreshSummaryDataType - { - RequestId = request.RequestId ?? string.Empty, - Generation = dryRun ? 0 : newGeneration, - Outcome = overall, - Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure, - StartTime = start, - EndTime = DateTime.UtcNow, - Total = (uint)results.Count, - Succeeded = (uint)succeeded, - Unchanged = (uint)unchanged, - Failed = (uint)failed, - Skipped = (uint)skipped, - Retired = (uint)retired - }; - - RaiseEvent(new WotMaterializationEventArgs( - WotMaterializationEventKind.RefreshCompleted) - { - Generation = newGeneration, - RequestId = request.RequestId ?? string.Empty, - Outcome = overall, - Summary = summary - }); + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = dryRun ? 0 : newGeneration, + Outcome = overall, + Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure, + StartTime = start, + EndTime = DateTime.UtcNow, + Total = (uint)results.Count, + Succeeded = (uint)succeeded, + Unchanged = (uint)unchanged, + Failed = (uint)failed, + Skipped = (uint)skipped, + Retired = (uint)retired + }; + + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.RefreshCompleted) + { + Generation = newGeneration, + RequestId = request.RequestId ?? string.Empty, + Outcome = overall, + Summary = summary + }); - return new WotRefreshResult( - summary, results.ToImmutable(), dryRun ? 0u : newGeneration); + return new WotRefreshResult( + summary, results.ToImmutable(), dryRun ? 0u : newGeneration); + } + finally + { + m_mutex.Release(); + } } finally { - m_mutex.Release(); + EndOperation(); } } @@ -243,29 +255,41 @@ await m_registry.ApplyProjectionResultsAsync( /// public async ValueTask RemoveAllAsync(CancellationToken cancellationToken = default) { - await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + if (!TryBeginOperation(allowDisposed: true)) + { + return; + } + try { - foreach (ClosureState state in m_closures.Values) + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - // Deactivate bindings before removing the projection (before - // retirement / unload), then release the projection handle. - foreach (WotBindingPlan plan in state.BindingPlans) - { - await m_binders.DeactivateAsync(plan, cancellationToken) - .ConfigureAwait(false); - } - if (state.Handle is not null) + foreach (ClosureState state in m_closures.Values) { - await m_host.RemoveAsync(state.Handle, cancellationToken) - .ConfigureAwait(false); + // Deactivate bindings before removing the projection (before + // retirement / unload), then release the projection handle. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + if (state.Handle is not null) + { + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + } } + m_closures.Clear(); + } + finally + { + m_mutex.Release(); } - m_closures.Clear(); } finally { - m_mutex.Release(); + EndOperation(); } } @@ -302,7 +326,60 @@ await m_host.RemoveAsync(state.Handle, cancellationToken) /// public void Dispose() { - m_mutex.Dispose(); + bool disposeMutex; + lock (m_lifetimeLock) + { + if (m_disposed != 0) + { + return; + } + m_disposed = 1; + disposeMutex = TryReserveMutexDisposal(); + } + if (disposeMutex) + { + m_mutex.Dispose(); + } + } + + private bool TryBeginOperation(bool allowDisposed) + { + lock (m_lifetimeLock) + { + if (m_mutexDisposed || (!allowDisposed && m_disposed != 0)) + { + return false; + } + m_activeOperations++; + return true; + } + } + + private void EndOperation() + { + bool disposeMutex; + lock (m_lifetimeLock) + { + m_activeOperations--; + disposeMutex = TryReserveMutexDisposal(); + } + if (disposeMutex) + { + m_mutex.Dispose(); + } + } + + private bool TryReserveMutexDisposal() + { + if (m_disposed == 0 || + m_activeOperations != 0 || + m_closures.Count != 0 || + m_mutexDisposed) + { + return false; + } + m_mutexDisposed = true; + return true; } private static ByteString DigestOf(WotResource resource) @@ -1295,6 +1372,7 @@ public ClosureOutcome( private readonly IWotNodeSetResolver? m_nodeSetResolver; private readonly WotNodeSetConverterOptions m_converterOptions; private readonly SemaphoreSlim m_mutex = new(1, 1); + private readonly System.Threading.Lock m_lifetimeLock = new(); private readonly Dictionary m_closures = new(StringComparer.Ordinal); @@ -1302,5 +1380,8 @@ public ClosureOutcome( new(StringComparer.Ordinal); private uint m_generation; + private int m_activeOperations; + private int m_disposed; + private bool m_mutexDisposed; } } diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs index dde714653d..16460ef4c1 100644 --- a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs @@ -126,15 +126,15 @@ await m_server .ConfigureAwait(false); } - m_coordinator?.Dispose(); - m_registry?.Dispose(); - m_server?.Dispose(); - if (m_fixture is not null) { await m_fixture.StopAsync().ConfigureAwait(false); } + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) { Directory.Delete(m_pkiRoot, recursive: true); diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs index e0d85771f4..1e4e27a8ee 100644 --- a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs @@ -133,23 +133,17 @@ public async Task SetUpAsync() [TearDown] public async Task TearDownAsync() { - if (m_requestHeader is not null) + await CloseActiveSessionAsync().ConfigureAwait(false); + + if (m_fixture is not null) { - m_requestHeader.Timestamp = DateTimeUtc.Now; - await m_server - .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) - .ConfigureAwait(false); + await m_fixture.StopAsync().ConfigureAwait(false); } m_coordinator?.Dispose(); m_registry?.Dispose(); m_server?.Dispose(); - if (m_fixture is not null) - { - await m_fixture.StopAsync().ConfigureAwait(false); - } - if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) { Directory.Delete(m_pkiRoot, recursive: true); @@ -252,6 +246,25 @@ await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "senso "The retired projection's nodes must be cleaned up after the resource is removed."); } + [Test] + public async Task ShutdownAfterCoordinatorDisposeRemovesMaterializedAddressSpaceAsync() + { + await UpsertSensorAsync("sensor", generation: 1).ConfigureAwait(false); + WotRefreshResult refresh = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(refresh.Results.Any(r => r.LoadState == WoTLoadStateEnum.Active), Is.True, + "The test must exercise shutdown with a live materialized projection."); + + await CloseActiveSessionAsync().ConfigureAwait(false); + m_coordinator.Dispose(); + m_coordinator = null!; + + Assert.That( + async () => await m_fixture.StopAsync().ConfigureAwait(false), + Throws.Nothing, + "Server shutdown must still delete the WoT address space after coordinator disposal."); + } + private async Task AssertRegistryProjectionAsync(IServerInternal server) { var registryNodeId = ExpandedNodeId.ToNodeId( @@ -280,6 +293,21 @@ private async Task AssertRegistryProjectionAsync(IServerInternal server) "The group must expose the registered resource document."); } + private async Task CloseActiveSessionAsync() + { + if (m_requestHeader is null) + { + return; + } + + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + m_requestHeader = null!; + m_secureChannelContext = null!; + } + [Test] public async Task CrudMethodsAndFileUploadDriveTheRegistryAsync() { From 98a507084690291c4d1500aefbbc18aca51e25b0 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 4 Aug 2026 13:11:13 +0200 Subject: [PATCH 7/7] Skip projection lifecycle removal during shutdown During ordered server disposal, the NodeManager lifecycle has already entered shutdown before WotRegistryNodeManager deletes its address space. Removing materialized WoT projections by routing back through the lifecycle is therefore the wrong operation: the lifecycle will tear down every remaining dynamic NodeManager as part of its shutdown completion. Expose the lifecycle shutdown state and make the WoT projection host treat removal as a no-op once that state is active. Normal refresh/removal paths still unregister projections through the lifecycle, while full server teardown avoids re-entering a component that is already shutting down. The shutdown regression now exercises synchronous server disposal with active projections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../NodeManager/Lifecycle/NodeManagerLifecycle.cs | 5 +++++ .../Materialization/LifecycleWotProjectionHost.cs | 5 +++++ .../RuntimeNodeSet/WotRegistryLifecycleTests.cs | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs index d48f875d9e..0e39b76b26 100644 --- a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs +++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs @@ -75,6 +75,11 @@ public ArrayOf Registrations } } + /// + /// Gets whether the owning server has started its ordered shutdown sequence. + /// + public bool IsShuttingDown => m_shuttingDown; + internal long ShutdownCleanupProgress => Interlocked.Read(ref m_shutdownCleanupProgress); diff --git a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs index ce27a71acf..c3334726af 100644 --- a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs +++ b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs @@ -162,6 +162,11 @@ public async ValueTask RemoveAsync( WotProjectionHandle handle, CancellationToken cancellationToken = default) { + if (m_lifecycle is NodeManagerLifecycle { IsShuttingDown: true }) + { + return; + } + if (handle?.Registration is NodeManagerProjectionRegistration wrapper) { await m_lifecycle diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs index 1e4e27a8ee..5a77f45bdc 100644 --- a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs @@ -260,9 +260,9 @@ public async Task ShutdownAfterCoordinatorDisposeRemovesMaterializedAddressSpace m_coordinator = null!; Assert.That( - async () => await m_fixture.StopAsync().ConfigureAwait(false), + () => m_server.Dispose(), Throws.Nothing, - "Server shutdown must still delete the WoT address space after coordinator disposal."); + "Server disposal must still delete the WoT address space after coordinator disposal."); } private async Task AssertRegistryProjectionAsync(IServerInternal server)