mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
# Description Implements the **Asset Administration Shell** companion specifications on top of `marcschier/wot-type-binding`, in two halves that a Server chooses between: * **AAS V3** — the [draft](https://github.com/marcschier/opcua-drafts/tree/main/companion-specs/AAS) (`http://opcfoundation.org/UA/I4AAS/v3/`), all sixteen conformance units: the metamodel, the registry built on the abstract xRegistry base, generational materialization, federation, packages with digest-bound integrity, the Digital Product Passport vocabulary, and the Annex F bridge to WoT Thing Descriptions. * **AAS V2** — the published [OPC 30270](https://reference.opcfoundation.org/specs/OPC-30270/full) (`http://opcfoundation.org/UA/I4AAS/`), which maps the AAS V2.0.1 metamodel: object model, ingestion of XML, JSON and AASX, materialization, server and client. The two are different metamodel generations rather than two revisions of one model — V2 keeps `Asset` as a first-class Identifiable, `identification.idType`, Views and ordered collections, none of which survive into V3 — so they are **alternatives**. A Server hosts one or the other; this is documented rather than enforced at startup. Each is named for the generation it maps (`Opc.Ua.Aas.V2`, `Opc.Ua.Aas.V3`, `AddAasV2Server`, `AddAasV3Server`), and what genuinely serves both carries no marker and sits at the root of `Opc.Ua.Aas`: the deterministic NodeId encoding and idShortPath conventions, `AasOptional<T>`, the digest rule, the materialization diagnostics, the AASX container walk, and the value and operation providers, which are keyed on `NodeId` and `Variant` and so carry no version at all. ## What is in here **New libraries**: `Opc.Ua.Aas`, `Opc.Ua.Aas.Server`, `Opc.Ua.Aas.Client`, `Opc.Ua.Aas.WoT`, plus `samples/Aas` and `docs/Aas.md`. **Enabling changes to existing code**, each needed by the above: * The OPC UA `Decimal` DataType added to `Opc.Ua.Types`, with its Part 6 §5.1.10 wire encoding. `BinaryDecoder` gained one internal member, `TryReadRemainingBodyBytes`, because a trailing field with no self-describing length cannot otherwise be read; `Decimal.Decode` is its only caller. Part 6 Table 3 types the unscaled `Value` as an **OctetString whose byte count is `Length` − 2**, not a length-prefixed `ByteString`, so it carries no length of its own — `DecimalEncodingTests` pins the body byte-for-byte against that mistake, and against the `Length ≤ 2` values the same table calls invalid. * The xRegistry projection engine extracted into `Opc.Ua.XRegistry.Server` and `Opc.Ua.WotCon.Server` migrated onto it (−848/+387 lines), with the WoT Connectivity tests passing **unedited**. * Two source-generator fixes, both of which made a model impossible to build (see below). ## Defects found and fixed along the way Running the stack end to end, rather than trusting per-area test suites, surfaced defects that no unit test caught. The ones worth a maintainer's attention: * **The generator emitted non-terminating constructors.** A structure field with no declared default was initialized with `new T()`, so a self-referential DataType (`AASReferenceDataType.referredSemanticId`) recursed until the stack overflowed and `AddOpcUaAas` could not load the model at all. The test host *died* rather than failing. Only the field that closes a cycle is now left null. * **The generator omitted `new` on a redeclared placeholder.** `AASOrderedSubmodelElementCollectionType` redeclares the `SubmodelElement` slot of its base type, and the generated `Add` method hid the base one without saying so, so the V2 model would not compile. No existing model could have hit either defect — both are build failures, so anything that builds today is unaffected. * **The metamodel half had never worked outside its unit tests**: the emitted NodeSet declared no reference-type aliases and was rejected by the loader; the Mandatory `Invoke` Method was never emitted, because a NodeSet is imported as written rather than instantiated from its type; the runtime bound at the authored namespace index instead of the one the Server assigns; and `MethodDeclarationId` was addressed in the wrong namespace. Every materializer test inspected the in-memory object graph, which cannot see whether the graph is loadable — two tests now import it exactly as `RuntimeNodeSetNodeManager` does. * **The client could not see any Property member.** It browsed `HasComponent` with subtypes, but `HasProperty` is a sibling under `Aggregates`, so reading a value failed and invoking failed argument validation. * **Every repeated registry write forked a duplicate group and resource** instead of adding a version, because the identifier was re-derived against a sibling set that already contained the entity itself. This also made the persistence bounds unreachable. * `.gitignore`'s `**/packages/*` silently swallowed the whole package-integrity implementation and its tests, which would have vanished on a fresh clone. ## Specification findings Nine findings against the V3 draft are fixed in [marcschier/opcua-drafts#75](https://github.com/marcschier/opcua-drafts/pull/75). The substantive one: clause 6.1.5's absent-versus-empty rule was **unsatisfiable** for five container fields that materialize as parent components with no node of their own, which made `AAS-LosslessRoundTrip` unachievable. OPC 30270 contradicts itself twice. Both are recorded in `AasV2ConformanceUnits.cs` and the documentation rather than smoothed over: Table 83 spells the unit `I4AAS MultiLangaugeProperty` where Table 85 spells it correctly, and Table 85 lists an `I4AAS Security` unit that Table 83 never defines. Table 84, which would assign profile URIs, is empty, so `ServerProfileArray` gains no I4AAS entry. ## Known limitation Calling an `Operation` **with arguments** answers `BadTooManyArguments`. Method resolution works from both the instance NodeId and the `MethodDeclarationId`, and the argument definitions are published, but `NodeState.AddChild` does not route through `MethodState.FindChild`, so a Method imported from a NodeSet leaves its typed `InputArguments` null. Closing this means changing how the shared NodeSet import claims typed children, which affects every consumer of runtime NodeSets and is out of scope here. It is documented in `docs/Aas.md` §11, the conformance matrix marks `AAS-OperationInvoke` partial rather than claiming it, and `samples/Aas` exercises the path and reports the status rather than working around it. V2 is unaffected — its `Operation` is an OptionalPlaceholder with no declared arguments. ## Related Issues - No tracking issue yet. Opening as a **draft** for design discussion first, per the ADR guidance below; happy to open one and link it once the shape is agreed. ## 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. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. ### Verification | Check | Result | |---|---| | AAS tests | **670 passed, 0 failed** on net472, net48, net8.0, net9.0, net10.0 | | Coverage | Client 80.8 %, Server 81.2 %, `Opc.Ua.Aas` 82.0 %, WoT 82.4 % — all above the 80 % gate | | Libraries | `Opc.Ua.Aas`, `.Server`, `.Client`, `.WoT` build clean on every target framework, zero warnings | | Regression | `Opc.Ua.Types` 8677, `Opc.Ua.WotCon` 1094, `Opc.Ua.XRegistry` 198, `Opc.Ua.Server` 4050 — all green | | Sample | `samples/Aas` builds warning-free and runs end to end | | Merge validation | Solution builds clean on net10.0 and net48; WoT Types 504, WotCon 1111, AAS 670, XRegistry 198 tests passed | | CI | All 99 checks green on `ff9298e44`; only the CLA signature status is outstanding | ### Review pass A full review against both specifications, and for API consistency and correctness, produced twelve fixes. Each one has a test that fails without it. | Defect | Where | Why it mattered | |---|---|---| | V2 projection aborted on **every** `File` read from a document | `AasV2EnvironmentRuntime` | The runtime bound `File/Value` unconditionally, the materializer emitted it only with content, and the builder throws `BadNotFound` for an unknown NodeId — aborting the whole registration. No reader ever populates `AasFile.File`, so no AAS V2 document could be hosted at all. | | `aas-suppl` anchored on the wrong part | `AasxPackageReaderCore`, `AasxPackageWriter` | IDTA and the Package Explorer anchor it on the environment part; we used the origin part. Every real `.aasx` returned zero supplementary files, silently. Affects both halves. | | Federation failed closed on pooled connections | `AasFederation` | `ConnectCallback` fires only for new connections, so the second fetch to an origin — and all of net472/net48/netstandard2.1 — failed with a security-flavoured error. The peer is now validated *inside* the callback, which makes reuse safe by construction. | | Malformed `Decimal` escaped as `ArgumentOutOfRangeException` | `BinaryDecoder` | A declared body shorter than `Scale` produced a negative read length. Two of the three test cases had been passing for the wrong reason. | | V2 and V3 cross-wired their value providers | `OpcUaAasServerBuilderExtensions` | Both registered `IAasValueProvider`; whichever was added first served both AddressSpaces. V2 now has its own contract, so co-hosting is harmless rather than silently wrong. `EnvironmentFolder` was also bound and ignored on the V2 path. | | Export filter skipped three nesting kinds | `AasEnvironmentExporter` | Content under `Entity`, `AnnotatedRelationshipElement` and `Operation` was exported unevaluated *and* stamped with a Digest presenting it as complete (clause 6.5.10). List authorization paths also shifted after a denial. | | Concealment held at the Method but not in the projection | `AasRegistryModel` | `GetSubmodel` answered `BadNotFound` while an anonymous Browse revealed the node, its identifier, semanticId and digest (clause 6.5.7). | | File handles were unsynchronized and unowned | `AasElementFileManager` | The async node manager holds no lock, so concurrent Calls raced a bare `Dictionary`; sequential handles let any Session read, seek or commit another's. Writes were also unbounded. | | Projection host was unimplementable outside the stack | `AasEnvironmentProjection` | The handle demanded a type with an internal constructor — our own tests used `FormatterServices.GetUninitializedObject` to get round it. Now mirrors WotCon's interface-typed registration. | | Embedded `FileType` omitted 7 of 10 Mandatory members | `AasEnvironmentMaterializer` (V2) | OPC 10000-3 6.4.4 requires them all; without `Size` and `OpenCount` a Client cannot size a read or detect an abandoned handle. | | Round-trip oracle ignored most of the model | `AasRoundTripComparer` | `Blob`, `File`, `ReferenceElement` and `BasicEventElement` had no case arm, so their fields compared equal by falling off the switch; `valueType`, `semanticId`, `entityType` and `Key.type` were never read. The clause 6.4 losslessness claim rested on it. | Known, unchanged: calling an `Operation` with arguments answers `BadTooManyArguments`, because `NodeState.AddChild` does not route through `MethodState.FindChild`. Documented in `docs/Aas.md` §11. Not addressed in this pass, and worth a maintainer decision: OPC 30270 §9.2 says all NodeIds of Nodes not defined in the companion specification **shall not** use the standard namespaces, and Table 87 marks a vendor-specific instance namespace Mandatory. Both halves currently mint instances in the companion namespace. Separating them touches NodeId minting, the runtime and the client together, so it is better done deliberately than folded into a review pass. This is a large change and touches shared code (`Opc.Ua.Types`, the source generator, the xRegistry projection). It is deliberately a draft: the enabling changes are separable if maintainers would prefer them split into their own PRs. The latest `master` is merged into this branch in `0dd0ff0e1`. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 Copilot-Session: c3d88950-4a65-4ff6-87d2-1f545a875c9b Copilot-Session: b8ab3763-b9bd-46ae-b0ef-045db26e5782
592 lines
26 KiB
C#
592 lines
26 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 Opc.Ua.Aas.V3;
|
|
using System;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Net.Sockets;
|
|
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.Aas;
|
|
using Opc.Ua.Aas.Client;
|
|
using Opc.Ua.Aas.Client.Registry;
|
|
using Opc.Ua.Aas.Server;
|
|
using Opc.Ua.Aas.Server.Materialization;
|
|
using Opc.Ua.Aas.Server.Packaging;
|
|
using Opc.Ua.Aas.Server.Registry;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Server.Hosting;
|
|
|
|
namespace AasSample
|
|
{
|
|
/// <summary>
|
|
/// Runs the AAS server, materialization step, and demonstration client.
|
|
/// </summary>
|
|
public static class AasSampleRunner
|
|
{
|
|
/// <summary>
|
|
/// Runs the sample from command-line configuration.
|
|
/// </summary>
|
|
public static async Task RunAsync(
|
|
string[] args,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
HostApplicationBuilder configurationBuilder = Host.CreateApplicationBuilder(args);
|
|
var options = new AasSampleOptions
|
|
{
|
|
EndpointUrl = configurationBuilder.Configuration["endpoint"],
|
|
Host = configurationBuilder.Configuration["host"] ?? "localhost",
|
|
Port = ReadPort(configurationBuilder.Configuration),
|
|
ApplicationName = configurationBuilder.Configuration["applicationName"] ?? "AasSample",
|
|
PkiRoot = configurationBuilder.Configuration["pkiRoot"]
|
|
};
|
|
AasSampleResult result = await RunAsync(options, cancellationToken).ConfigureAwait(false);
|
|
Print(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the sample from explicit options.
|
|
/// </summary>
|
|
public static async Task<AasSampleResult> RunAsync(
|
|
AasSampleOptions options,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Validate(options);
|
|
AasSampleDataset dataset = await AasSampleData.CreateAsync(cancellationToken).ConfigureAwait(false);
|
|
using var registry = new AasRegistryService();
|
|
for (int ii = 0; ii < dataset.RegistryRequests.Count; ii++)
|
|
{
|
|
await registry.UpsertResourceAsync(dataset.RegistryRequests[ii], cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
using IHost server = BuildServer(options, registry);
|
|
await server.StartAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await WaitForTcpAsync(options.Port, cancellationToken).ConfigureAwait(false);
|
|
await WaitForSessionAsync(options, cancellationToken).ConfigureAwait(false);
|
|
var store = new AasSampleMaterializationDocumentStore(dataset.MaterializationDocuments);
|
|
var projectionHost = new AasSampleProjectionHost(
|
|
server.Services.GetRequiredService<IAasEnvironmentProjectionHost>(),
|
|
new AasSampleOperationHandler());
|
|
using var coordinator = new AasMaterializationCoordinator(
|
|
store,
|
|
projectionHost);
|
|
AasMaterializeResult materialize = await coordinator.MaterializeAsync(
|
|
new AasMaterializeRequest { Force = true },
|
|
cancellationToken).ConfigureAwait(false);
|
|
AasClientObservation observation = await RunClientAsync(
|
|
options,
|
|
dataset,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return new AasSampleResult(dataset, materialize, store.States, observation, registry.Current.Generation);
|
|
}
|
|
finally
|
|
{
|
|
await server.StopAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static IHost BuildServer(
|
|
AasSampleOptions options,
|
|
IAasRegistryService registry)
|
|
{
|
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
|
|
builder.Logging.ClearProviders();
|
|
builder.Logging.AddConsole();
|
|
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
|
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:AasSample";
|
|
if (!string.IsNullOrWhiteSpace(options.PkiRoot))
|
|
{
|
|
server.PkiRoot = options.PkiRoot;
|
|
}
|
|
server.AutoAcceptUntrustedCertificates = true;
|
|
server.IncludeUnsecurePolicyNone = true;
|
|
server.EndpointUrls.Add(options.Endpoint);
|
|
});
|
|
opcUa.AddAasV3Server(aas => aas.ControlNamespaceUri = AasSampleData.InstanceNamespaceUri)
|
|
.AddEnvironmentProvider(_ => new InMemoryAasEnvironmentProvider([]))
|
|
.AddOperationHandler<AasSampleOperationHandler>();
|
|
builder.Services.AddSingleton(registry);
|
|
builder.Services.AddSingleton<IAasRegistryService>(registry);
|
|
builder.Services.AddSingleton<AasRegistryNodeManagerFactory>();
|
|
builder.Services.AddSingleton(sp => new OpcUaServerNodeManagerRegistration(
|
|
sp.GetRequiredService<AasRegistryNodeManagerFactory>()));
|
|
return builder.Build();
|
|
}
|
|
|
|
private static async Task<AasClientObservation> RunClientAsync(
|
|
AasSampleOptions options,
|
|
AasSampleDataset dataset,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using IHost clientHost = BuildClient(options);
|
|
await clientHost.StartAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
Func<CancellationToken, Task<ManagedSession>> connect = clientHost.Services
|
|
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
|
|
ManagedSession session = await connect(cancellationToken).ConfigureAwait(false);
|
|
await using (session.ConfigureAwait(false))
|
|
{
|
|
await session.FetchNamespaceTablesAsync(cancellationToken).ConfigureAwait(false);
|
|
session.MessageContext.NamespaceUris.Update(session.NamespaceUris.ToArray());
|
|
Func<ManagedSession, CancellationToken, Task<AasRegistryClient>> registryFactory = clientHost.Services
|
|
.GetRequiredService<Func<ManagedSession, CancellationToken, Task<AasRegistryClient>>>();
|
|
Func<ManagedSession, CancellationToken, Task<AasClient>> aasFactory = clientHost.Services
|
|
.GetRequiredService<Func<ManagedSession, CancellationToken, Task<AasClient>>>();
|
|
AasRegistryClient registry = await registryFactory(session, cancellationToken).ConfigureAwait(false);
|
|
AasClient aas = await aasFactory(session, cancellationToken).ConfigureAwait(false);
|
|
int shellMatches = await LookupShellCountAsync(registry, session, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
AasGetSubmodelDocumentResult submodel = await registry.GetSubmodelAsync(
|
|
AasSampleData.PassportSubmodelId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
NodeId carbonFootprintNodeId = aas.CreateSubmodelElementNodeId(
|
|
AasSampleData.PassportSubmodelId,
|
|
"CarbonFootprint");
|
|
Opc.Ua.Aas.Client.AasValueReadResult carbonFootprint = await aas.ReadValueAsync(
|
|
carbonFootprintNodeId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
NodeId operationNodeId = aas.CreateSubmodelElementNodeId(
|
|
AasSampleData.PassportSubmodelId,
|
|
"RecalculatePassport");
|
|
Opc.Ua.Aas.Client.AasOperationInvokeResult operation = await aas.InvokeAsync(
|
|
operationNodeId,
|
|
new Variant[] { new("operator-request") }.ToArrayOf(),
|
|
ArrayOf<Variant>.Empty,
|
|
0d,
|
|
cancellationToken).ConfigureAwait(false);
|
|
AasPackageIntegrityResult packageCheck = AasPackageIntegrity.VerifyConsumerBlob(
|
|
dataset.Package,
|
|
AasPackageIntegrity.Sha256,
|
|
dataset.PackageDigest);
|
|
return new AasClientObservation(
|
|
shellMatches,
|
|
submodel.StatusCode,
|
|
submodel.Document.Length,
|
|
carbonFootprint.LexicalValue,
|
|
operation.CallStatusCode,
|
|
operation.Success,
|
|
operation.Diagnostic,
|
|
packageCheck.Succeeded);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await clientHost.StopAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static IHost BuildClient(AasSampleOptions options)
|
|
{
|
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
|
|
builder.Logging.ClearProviders();
|
|
builder.Logging.AddConsole();
|
|
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
|
builder.Services
|
|
.AddOpcUa()
|
|
.AddOpcTcpTransport()
|
|
.AddClient(client =>
|
|
{
|
|
client.ApplicationName = options.ApplicationName + ".Client";
|
|
client.ApplicationUri = $"urn:localhost:OPCFoundation:{options.ApplicationName}.Client";
|
|
client.ProductUri = "uri:opcfoundation.org:AasSample.Client";
|
|
if (!string.IsNullOrWhiteSpace(options.PkiRoot))
|
|
{
|
|
client.PkiRoot = Path.Combine(options.PkiRoot, "client");
|
|
}
|
|
client.AutoAcceptUntrustedCertificates = true;
|
|
client.Session = new ManagedSessionOptions
|
|
{
|
|
SessionName = options.ApplicationName + ".Client",
|
|
SessionTimeout = TimeSpan.FromSeconds(60)
|
|
};
|
|
})
|
|
.AddDiscoveryAndConnect(discovery =>
|
|
{
|
|
discovery.DiscoveryUrl = options.Endpoint;
|
|
discovery.SecurityMode = MessageSecurityMode.None;
|
|
discovery.SecurityPolicyUri = SecurityPolicies.None;
|
|
})
|
|
.AddAasV3Client(aas => aas.InstanceNamespaceUri = Opc.Ua.Aas.V3.Namespaces.AasV3)
|
|
.AddAasV3RegistryClient();
|
|
return builder.Build();
|
|
}
|
|
|
|
private static async ValueTask<int> LookupShellCountAsync(
|
|
AasRegistryClient registry,
|
|
ManagedSession session,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
ArrayOf<NodeId> shells = await registry.LookupShellsByAssetLinkAsync(
|
|
"serialNumber",
|
|
"BP-2026-0001",
|
|
cancellationToken).ConfigureAwait(false);
|
|
return shells.Count;
|
|
}
|
|
catch (ServiceResultException)
|
|
{
|
|
CallResponse response = await session.CallAsync(
|
|
null,
|
|
new[]
|
|
{
|
|
new CallMethodRequest
|
|
{
|
|
ObjectId = registry.RegistryNodeId,
|
|
MethodId = ExpandedNodeId.ToNodeId(
|
|
Opc.Ua.Aas.V3.MethodIds.AASRegistryType_LookupShellsByAssetLink,
|
|
session.NamespaceUris),
|
|
InputArguments =
|
|
[
|
|
new Variant("serialNumber"),
|
|
new Variant("BP-2026-0001")
|
|
]
|
|
}
|
|
}.ToArrayOf(),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (response.Results.Count == 0 || StatusCode.IsBad(response.Results[0].StatusCode))
|
|
{
|
|
return 0;
|
|
}
|
|
if (response.Results[0].OutputArguments.Count > 0 &&
|
|
response.Results[0].OutputArguments[0].TryGetValue(out ArrayOf<NodeId> nodes))
|
|
{
|
|
return nodes.Count;
|
|
}
|
|
if (response.Results[0].OutputArguments.Count > 0 &&
|
|
response.Results[0].OutputArguments[0].TryGetValue(out ArrayOf<string> shellIds))
|
|
{
|
|
return shellIds.Count;
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private static async Task WaitForSessionAsync(AasSampleOptions options, CancellationToken cancellationToken)
|
|
{
|
|
Exception? last = null;
|
|
for (int ii = 0; ii < 60; ii++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
using IHost clientHost = BuildClient(options);
|
|
await clientHost.StartAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
Func<CancellationToken, Task<ManagedSession>> connect = clientHost.Services
|
|
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
|
|
ManagedSession session = await connect(cancellationToken).ConfigureAwait(false);
|
|
await using (session.ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await clientHost.StopAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is SocketException or ServiceResultException or InvalidOperationException)
|
|
{
|
|
last = ex;
|
|
}
|
|
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
throw new InvalidOperationException("The sample server did not accept a session.", last);
|
|
}
|
|
private static async Task WaitForTcpAsync(int port, CancellationToken cancellationToken)
|
|
{
|
|
Exception? last = null;
|
|
for (int ii = 0; ii < 100; ii++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
using var client = new TcpClient();
|
|
#if NET8_0_OR_GREATER
|
|
await client.ConnectAsync("127.0.0.1", port, cancellationToken).ConfigureAwait(false);
|
|
#else
|
|
await client.ConnectAsync("127.0.0.1", port).ConfigureAwait(false);
|
|
#endif
|
|
return;
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
last = ex;
|
|
}
|
|
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
throw new InvalidOperationException("The sample server did not start listening.", last);
|
|
}
|
|
|
|
private static void Print(AasSampleResult result)
|
|
{
|
|
Console.WriteLine("AAS sample completed.");
|
|
Console.WriteLine($"Registry generation: {result.RegistryGeneration}");
|
|
Console.WriteLine(
|
|
$"Materialization generation: {result.Materialization.Generation}, " +
|
|
$"documents: {result.Materialization.Results.Count}");
|
|
for (int ii = 0; ii < result.Materialization.Results.Count; ii++)
|
|
{
|
|
AasMaterializationResultData item = result.Materialization.Results[ii];
|
|
Console.WriteLine($" {item.Xid}: {item.Outcome} -> {item.MaterializedNode}");
|
|
if (!string.IsNullOrEmpty(item.Diagnostic))
|
|
{
|
|
Console.WriteLine($" diagnostic: {item.Diagnostic}");
|
|
}
|
|
}
|
|
Console.WriteLine(
|
|
$"DPP semantic IRI: {result.Dataset.CarbonFootprintIdentifier.Iri} " +
|
|
$"({result.Dataset.CarbonFootprintIdentifier.Rule})");
|
|
Console.WriteLine(
|
|
$"DPP tiers: passport={result.Dataset.PassportDisclosure.Tier}, " +
|
|
$"controlled={result.Dataset.ControlledDisclosure.Tier}");
|
|
Console.WriteLine($"LookupShellsByAssetLink matches: {result.ClientObservation.ShellMatches}");
|
|
Console.WriteLine(
|
|
$"GetSubmodel status: {result.ClientObservation.GetSubmodelStatus}, " +
|
|
$"bytes: {result.ClientObservation.GetSubmodelBytes}");
|
|
Console.WriteLine($"CarbonFootprint typed value: {result.ClientObservation.CarbonFootprintLexical}");
|
|
Console.WriteLine(
|
|
$"Invoke RecalculatePassport: {result.ClientObservation.OperationStatus}, " +
|
|
$"success={result.ClientObservation.OperationSuccess}, " +
|
|
$"diagnostic='{result.ClientObservation.OperationDiagnostic}'");
|
|
Console.WriteLine($"Package digest verified: {result.ClientObservation.PackageVerified}");
|
|
}
|
|
|
|
private static int ReadPort(ConfigurationManager configuration)
|
|
{
|
|
return int.TryParse(
|
|
configuration["port"],
|
|
NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture,
|
|
out int port)
|
|
? port
|
|
: 62560;
|
|
}
|
|
|
|
private static void Validate(AasSampleOptions options)
|
|
{
|
|
if (options is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(options));
|
|
}
|
|
if (string.IsNullOrWhiteSpace(options.ApplicationName))
|
|
{
|
|
throw new ArgumentException("ApplicationName is required.", 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));
|
|
}
|
|
}
|
|
|
|
private sealed class AasSampleProjectionHost : IAasEnvironmentProjectionHost
|
|
{
|
|
public AasSampleProjectionHost(
|
|
IAasEnvironmentProjectionHost inner,
|
|
IAasOperationHandler operationHandler)
|
|
{
|
|
m_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
|
m_operationHandler = operationHandler ?? throw new ArgumentNullException(nameof(operationHandler));
|
|
}
|
|
|
|
public ValueTask<AasEnvironmentProjectionHandle> AddAsync(
|
|
AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.AddAsync(environment, valueProvider, m_operationHandler, cancellationToken);
|
|
}
|
|
|
|
public ValueTask<AasEnvironmentProjectionHandle> ShadowReloadAsync(
|
|
AasEnvironmentProjectionHandle current,
|
|
AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.ShadowReloadAsync(
|
|
current,
|
|
environment,
|
|
valueProvider,
|
|
m_operationHandler,
|
|
cancellationToken);
|
|
}
|
|
|
|
public ValueTask<AasEnvironmentProjectionHandle> ImmediateReloadAsync(
|
|
AasEnvironmentProjectionHandle current,
|
|
AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.ImmediateReloadAsync(
|
|
current,
|
|
environment,
|
|
valueProvider,
|
|
m_operationHandler,
|
|
cancellationToken);
|
|
}
|
|
|
|
public ValueTask RemoveAsync(
|
|
AasEnvironmentProjectionHandle handle,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.RemoveAsync(handle, cancellationToken);
|
|
}
|
|
|
|
// The projection host serves either AAS metamodel generation. This
|
|
// sample only projects V3, so the V2 overloads delegate straight
|
|
// through rather than substituting the sample's operation handler.
|
|
public ValueTask<AasEnvironmentProjectionHandle> AddAsync(
|
|
Opc.Ua.Aas.V2.AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.AddAsync(environment, valueProvider, operationHandler, cancellationToken);
|
|
}
|
|
|
|
public ValueTask<AasEnvironmentProjectionHandle> ShadowReloadAsync(
|
|
AasEnvironmentProjectionHandle current,
|
|
Opc.Ua.Aas.V2.AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.ShadowReloadAsync(
|
|
current,
|
|
environment,
|
|
valueProvider,
|
|
operationHandler,
|
|
cancellationToken);
|
|
}
|
|
|
|
public ValueTask<AasEnvironmentProjectionHandle> ImmediateReloadAsync(
|
|
AasEnvironmentProjectionHandle current,
|
|
Opc.Ua.Aas.V2.AasEnvironment environment,
|
|
IAasValueProvider valueProvider,
|
|
IAasOperationHandler operationHandler,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.ImmediateReloadAsync(
|
|
current,
|
|
environment,
|
|
valueProvider,
|
|
operationHandler,
|
|
cancellationToken);
|
|
}
|
|
|
|
private readonly IAasEnvironmentProjectionHost m_inner;
|
|
private readonly IAasOperationHandler m_operationHandler;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures the result of one end-to-end run.
|
|
/// </summary>
|
|
public sealed class AasSampleResult
|
|
{
|
|
/// <summary>
|
|
/// Initializes a sample result.
|
|
/// </summary>
|
|
public AasSampleResult(
|
|
AasSampleDataset dataset,
|
|
AasMaterializeResult materialization,
|
|
ArrayOf<AasMaterializationDocumentState> materializationStates,
|
|
AasClientObservation clientObservation,
|
|
long registryGeneration)
|
|
{
|
|
Dataset = dataset;
|
|
Materialization = materialization;
|
|
MaterializationStates = materializationStates;
|
|
ClientObservation = clientObservation;
|
|
RegistryGeneration = registryGeneration;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the sample data.
|
|
/// </summary>
|
|
public AasSampleDataset Dataset { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the materialization result.
|
|
/// </summary>
|
|
public AasMaterializeResult Materialization { get; }
|
|
|
|
/// <summary>
|
|
/// Gets persisted materialization states.
|
|
/// </summary>
|
|
public ArrayOf<AasMaterializationDocumentState> MaterializationStates { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the client observation.
|
|
/// </summary>
|
|
public AasClientObservation ClientObservation { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the registry generation after seeding.
|
|
/// </summary>
|
|
public long RegistryGeneration { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Values observed by the sample client.
|
|
/// </summary>
|
|
public sealed record AasClientObservation(
|
|
int ShellMatches,
|
|
StatusCode GetSubmodelStatus,
|
|
int GetSubmodelBytes,
|
|
string CarbonFootprintLexical,
|
|
StatusCode OperationStatus,
|
|
bool OperationSuccess,
|
|
string OperationDiagnostic,
|
|
bool PackageVerified);
|
|
}
|