Files
Marc Schier 55bd944609 WoT: Types-side TD/TM-NodeSet conversion with consolidated corpus and minimal consumer compat (#4758)
# Description

Adds the pure Types-side WoT Thing Description / Thing Model to NodeSet
conversion: resolution, context, declarations, types, values,
localization, URI/format handling, bounded hashing, plus supporting
native XML codec, NodeSet alias/value helpers, RelativePathFormatter and
a JsonElement equality polyfill.

- Carrier-witness fix: a missing native event is accepted only when the
owning preserved type declares it through a `GeneratesEvent` reference
(directly or via its type/supertype chain) AND a bounded, validated
selected EventType carrier identity equals the normalized authored
native target. Native-present events get a full compare; an ambiguous
native key and a missing map stay strict; a readable document is never
archive authority.
- Support-only dependencies: additive per-instance, non-serialized
`WotDocument.IsSupportOnly(kind, name)` (ArgumentNullException guard).
The resolver marks support-only after closure; explicit selection wins;
the View builder excludes only true support-only members, so unit/actsOn
dependencies stay in the JSON. Nested controls cover inner value-only
projection under an outer value-only selection (unit kept, support-only)
versus an outer explicit SelectAll (explicit, not support-only).
- Documentation/XML comments and member-order only in commit 5c368d3a;
no logic change.
- Timeout repair on current head 6957dd46: invocation-scoped
`ArchiveDerivations` reuse removes repeated document-set derivation work
without a global cache (functional files
`WotNodeSetConverter.{Archive,ArchiveDerivations,Partitions,WoT}.cs`,
plus `WotArchiveDerivationsTests` and
`WotDocumentSetSharedValidationTests`). The original local full29
reproduction changed from 27 passed / 2 Refresh timeouts to 29 passed
with unchanged limits; official CI on this head FAILED at three
per-project time ceilings (see the evidence ledger). Temporary RF1
diagnostics were fully removed; the final tree differs from 0f7b9a09
only in the six functional files.
- Test-only net48 compile repair in commit b838907 (earlier head):
official CI on 5c368d3a was RED on the net48 build with CS1061 (see
ledger); the fix adds `using System.Threading;` and an unqualified
`CancellationTokenSource` in one test file. No production change.

Defaults: DI default document budget 256 versus the sample's explicit
1024. Corpus: canonical 540-JSON inventory; the actual manifest is 535
entries = 394 TD + 141 TM (the roles of the other 5 are not claimed).
Native Pumps assertions (9624 nodes / 339 partitions) are unchanged.
2026-10-17 20:43:25 +02:00

246 lines
10 KiB
C#

/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.WotCon.Bindings;
using Opc.Ua.WotCon.Bindings.OpcUa;
using Opc.Ua.WotCon.Server;
namespace AggregationServer
{
/// <summary>
/// Builds and runs the reusable generic aggregation host.
/// </summary>
public static class AggregationServerHost
{
/// <summary>
/// Builds a host from explicit options.
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
public static IHost Build(AggregationServerOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
Configure(builder, options);
return builder.Build();
}
/// <summary>
/// Builds and runs a host from explicit options.
/// </summary>
public static async Task RunAsync(
AggregationServerOptions options,
CancellationToken cancellationToken = default)
{
using IHost host = Build(options);
await host.RunAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Builds and runs a host from command-line configuration.
/// </summary>
public static async Task RunAsync(
string[] args,
CancellationToken cancellationToken = default)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
var options = new AggregationServerOptions
{
EndpointUrl = builder.Configuration["endpoint"],
Host = builder.Configuration["host"] ?? "localhost",
Port = ReadPort(builder.Configuration),
ApplicationName =
builder.Configuration["applicationName"] ?? "AggregationServer",
PkiRoot = builder.Configuration["pkiRoot"],
MaximumDocumentBytes = ReadMaximumDocumentBytes(builder.Configuration)
};
Configure(builder, options);
using IHost host = builder.Build();
await host.RunAsync(cancellationToken).ConfigureAwait(false);
}
private static void Configure(
HostApplicationBuilder builder,
AggregationServerOptions options)
{
Validate(options);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Services.AddSingleton(options);
string endpoint = options.EndpointUrl ??
$"opc.tcp://{options.Host}:{options.Port}/AggregationServer";
IOpcUaBuilder opcUa = builder.Services.AddOpcUa();
opcUa.AddOpcTcpTransport();
opcUa.AddServer(server =>
{
server.ApplicationName = options.ApplicationName;
server.ApplicationUri =
$"urn:localhost:OPCFoundation:{options.ApplicationName}";
server.ProductUri = "uri:opcfoundation.org:AggregationServer";
if (!string.IsNullOrWhiteSpace(options.PkiRoot))
{
server.PkiRoot = options.PkiRoot;
}
server.AutoAcceptUntrustedCertificates = true;
server.IncludeUnsecurePolicyNone = true;
server.EndpointUrls.Add(endpoint);
});
opcUa.AddWotRegistryServer(registry =>
{
registry.AutoRefresh = false;
registry.Bounds.MaxDocumentBytes = options.MaximumDocumentBytes;
registry.ManagementAccess = new WotManagementAccessPolicy
{
MinimumSecurityMode = MessageSecurityMode.None,
AllowAnonymous = true,
RequiredRoleId = ObjectIds.WellKnownRole_Anonymous
};
});
// The bundled companion registry holds 540 partitioned documents (31 MiB), more than
// the default 256-document resolver budget, so the sample states a larger budget
// explicitly. The byte budgets stay at their defaults.
builder.Services.AddSingleton(new Opc.Ua.Wot.WotNodeSetConverterOptions
{
MaxJsonDocumentSize = options.MaximumDocumentBytes,
MaxResolverDocumentBytes = options.MaximumDocumentBytes,
MaxResolverDocuments = 1024
});
opcUa.AddClient(client =>
{
client.ApplicationName = $"{options.ApplicationName}.Upstream";
client.ApplicationUri =
$"urn:localhost:OPCFoundation:{options.ApplicationName}.Upstream";
client.ProductUri = "uri:opcfoundation.org:AggregationServer";
if (!string.IsNullOrWhiteSpace(options.PkiRoot))
{
client.PkiRoot = Path.Combine(options.PkiRoot, "upstream");
}
client.AutoAcceptUntrustedCertificates = true;
client.Session = new ManagedSessionOptions
{
SessionName = $"{options.ApplicationName}.Upstream",
SessionTimeout = TimeSpan.FromSeconds(60)
};
}).AddManagedClientPool();
opcUa.AddHttpWotBinding();
opcUa.AddModbusWotBinding();
// The aggregation topology deliberately federates source servers that run on the
// same host as this server, so the loopback gate the default policy applies must be
// opened explicitly. Keep every other check (scheme, blocked hosts, private ranges)
// at its secure default.
opcUa.AddWotEndpointPolicy(new WotEndpointPolicy { AllowLoopback = true });
builder.Services.AddSingleton<IWotBindingExecutor>(serviceProvider =>
{
IManagedSessionPool pool =
serviceProvider.GetRequiredService<IManagedSessionPool>();
return new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions
{
DisposeSession = false,
SessionFactory = async (url, ct) =>
{
var endpointDescription = new EndpointDescription
{
EndpointUrl = url,
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
};
var configuredEndpoint = new ConfiguredEndpoint(
null,
endpointDescription,
null);
return await pool.GetOrConnectAsync(url, configuredEndpoint, ct)
.ConfigureAwait(false);
}
});
});
}
private static int ReadPort(ConfigurationManager configuration)
{
return int.TryParse(
configuration["port"],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int port)
? port
: 62550;
}
private static int ReadMaximumDocumentBytes(ConfigurationManager configuration)
{
return int.TryParse(
configuration["maximumDocumentBytes"],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int maximumDocumentBytes)
? maximumDocumentBytes
: 32 * 1024 * 1024;
}
private static void Validate(AggregationServerOptions options)
{
if (string.IsNullOrWhiteSpace(options.ApplicationName))
{
throw new ArgumentException("ApplicationName is required.", nameof(options));
}
if (options.MaximumDocumentBytes <= 0)
{
throw new ArgumentException(
"MaximumDocumentBytes must be positive.",
nameof(options));
}
if (options.EndpointUrl is null &&
(string.IsNullOrWhiteSpace(options.Host) || options.Port is < 1 or > 65535))
{
throw new ArgumentException("A valid host and port are required.", nameof(options));
}
}
}
}