Files
UA-.NETStandard/samples/WotCon/AggregationServer/AggregationServerHost.cs
Marc Schier 90fc3cca2f Complete WoT Binding 1.1 alignment and conformance coverage (#4411)
# Description

Completes the UA-.NETStandard implementation of the final OPC UA WoT
Binding 1.1 semantics and aligns the WoT Connectivity 1.1 model with
xRegistry 0.4.0.

The change:

- retires `uav:isEvent` and `uav:severity` from current Binding output
while preserving unknown members through the generic converter's
ordinary residue mechanism;
- removes `uav:severity` from the asset Thing Description parser and
materializer, so document metadata cannot set occurrence severity;
- implements logical EventType definitions, ordered resolution
providers, explicit/default selection precedence, materialized member
paths, and the built-in `BaseEventType` fallback;
- adds deterministic portable identity and browse-path anchoring;
- populates bound type declarations instead of creating duplicate
siblings, including inherited and closed-content validation;
- resolves and validates `uav:externalSchema` without overriding
authoritative DataType definitions;
- hardens DataType dimensions, native projections, preservation
envelopes, URI transmission, projection limits, and delete-policy
handling;
- synchronizes the xRegistry and WoT Connectivity source-generation
inputs;
- pins all 27 specification examples, all 97 implementation-owned
normative requirements, and all 12 cross-language vector groups to the
final specification revision;
- adds `samples/WotCon/run-aggregation-demo.ps1` for a verified
one-command aggregation client/server round trip and replaces the README
topology sketch with Mermaid;
- normalizes redirected PowerShell diagnostic presentation in the
statement-digest test harness so its semantic assertions are stable on
Windows, Linux, and macOS.

WoT Connectivity remains model version `1.1`; draft iterations are
identified by release labels such as `1.1-draft5`.

**Specification dependency:** the evidence assets pin
`OPCF-Members/spec-drafts` `main` commit
`25db507121b503fd31c8816fbfae67a6822e813b`. The corresponding
specification follow-up is `OPCF-Members/spec-drafts#35`.

## Validation

- Latest GitHub/Azure CI: 266/266 checks passed.
- .NET 10 feedback-focused tests:
  - WoT Connectivity event/model/traceability: 72 passed
  - binding capability/traceability: 8 passed
  - coverage-gate tests: 25 passed
- Complete .NET 10 Tools project: 752 passed, 1 intentionally skipped.
- .NET Framework 4.8:
  - WoT Connectivity event/model/traceability: 72 passed
  - WotCon bindings build: 0 warnings, 0 errors
- NativeAOT build: 0 warnings, 0 errors.
- WoT aggregation demo: 16 documents uploaded and all 10 mapped values
read with Good status; exact processes and isolated PKI stores cleaned
up.
- Specification traceability: 402 normative statements mapped; all 97
implementation-owned statements have passing test evidence with zero
gaps.
- Shared conformance vectors: 12/12 groups execute.
- Final CI coverage:
  - project line coverage: 86.83% (floor 70%)
  - project branch coverage: 76.71% (floor 60%)
  - patch coverage: 100.00% (2,591/2,591 changed lines)
  - coverage gate passed

## Related Issues

Follow-up to #4375 and alignment with `OPCF-Members/spec-drafts#31` and
`OPCF-Members/spec-drafts#35`. No separate tracking issue was opened for
this follow-up.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce new build or
analyzer warnings in the changed paths.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed all PR feedback currently received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6a9f41b1-72f0-4c74-bd7f-26fa531e5738
2026-09-04 23:36:18 +02:00

236 lines
9.5 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
};
});
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));
}
}
}
}