mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
b25c1f6ad4
# Description Stacked on #4650. Until that is merged, this PR also shows its commits; the six commits of this PR start after `fix(server): keep conformance unit aggregation idempotent and ordered with registrations`. This adds OPC 10000-110 Asset Management Basics 1.01.1 (`http://opcfoundation.org/UA/AMB/`) as a companion SDK, in the pattern of the DI, Machinery, Pumps and Scales packages. The tracking issue #4649 describes the scope, the design and the open questions. In short: | Package | Content | |---|---| | `Opc.Ua.AMB` | the model generated by the source generator from the unmodified NodeSet, plus the contracts server and client share (`AssetFaultSeverity`, `AssetLocationKind`, `MaintenanceStateKind`, `DocumentationLinkProperties`) | | `Opc.Ua.AMB.Server` | a sidecar node manager next to the owner of the Device Integration address space (`AddOpcUaDi`, `AddMachinery`, `AddPumps` or `AddScales`), the asset registry `IAssetManagement` with a fluent builder, `AddAssetManagement()` hosting, memory and file system stores, the AMB conformance units and server facet | | `Opc.Ua.AMB.Client` | `AmbClient`: discovery, identification, health alarm and maintenance streams, documentation links, locations, structure and snapshots, plus `AddAssetManagementClient()` | **Sidecar.** An asset stays in the node manager that owns it. Everything AMB adds to it (`DeviceHealth`, health alarms, maintenance conditions, the DocumentationLinks AddIn, AssetId and the other properties) is created there. The AMB node manager serves the AMB model, the alias categories `Assets`, `AssetsByProductInstanceUri` and `AssetsByAssetId` below `0:Aliases`, the location hierarchies below `0:Locations` and the server-specific alarm and condition types. Owners register assets with the shared registry from their setup, in either startup order. **Implemented conformance units** (Table 54): Asset Identification, Configurable Asset Identification, Discovery by ProductInstanceUri and by AssetId, Health Status Base, Alarms, Root Causes and Alarm Categories, Version Information, Operation Counters (inspected only; AMB defines no counters), DocumentationLinks Base, Edit Base and Edit Advanced, Requirements, Capabilities, Classification, Current and Future Maintenance Activities, Local Time, the three location properties and both location object hierarchies, Sub-assets and Asset relations. The two history units (Health Tracking, Past Maintenance Activities) are not part of this PR. `docs/AMB.md` has the matrix with the rule the server applies for each unit and whether a test checks it over a session. **Dependency on #4650.** The health alarms and maintenance conditions are created through `CreateAlarm<TState>`, and the conformance units follow the registered assets through the recomputation in `ConformanceUnitsManager`. ## Changes Six commits, each building on its own: 1. **`feat(amb)`: model package** (`src/Opc.Ua.AMB/`, about 2.4k lines, of which the NodeSet and the two identifier tables are 1.6k). - The NodeSet is the publication from `UA-Nodeset/latest/AMB`, byte for byte (git blob `21f9475b646e9c5508171277ffe5b74edbeb0a58`). `.gitattributes` checks it out with LF endings, because a test pins its SHA-256. - The published identifier table is vendored unchanged; a derived table pins the symbol-to-NodeId mapping, and the generator fails the build when they disagree. `tools/nodesets/README.md` documents the one new kind of renamed symbol (top-level instances below `0:Aliases` and `0:Locations`). 2. **`feat(amb)`: server package** (`src/Opc.Ua.AMB.Server/`, about 10.7k lines). Node manager, registry and builders, discovery, health, maintenance (`MaintenanceEventStateMachine` with all transitions), documentation links, locations, version information, classification (IRDI dictionary entries below `Server/Dictionaries`) and structure, stores, conformance evaluation, hosting. Log messages use the EventId block 8300 to 8379, after Pumps (8100) and Scales (8200). 3. **`feat(amb)`: client package** (`src/Opc.Ua.AMB.Client/`, about 3.0k lines). Calls AddLink and RemoveLink through the generated `DocumentationLinksTypeClient`. 4. **`test(amb)`: test suite** (`tests/Opc.Ua.AMB.Tests/`, 163 tests, about 8.8k lines; commit 6 adds one more). 5. **`docs(amb)`: guide and samples**: `docs/AMB.md`, links from `README.md`, `docs/README.md`, `docs/Samples.md` and `docs/AliasNames.md`; `samples/AMB/AmbServer` (AMB next to `AddMachinery`: two DI devices and a Machinery press as assets, with a simulation) and `samples/AMB/AmbClient`. The sample server listens on 62553, which no other sample uses. 6. **`feat(amb)`: tell the links users added from the links of the manufacturer.** OPC 10000-110 gives a client no way to see which links `RemoveLink` accepts (§10.5.4: only those `AddLink` created). As §10.5.1 lets vendors add metadata Properties to the links, the server gives every link `AddLink` created, and every such link restored after a restart, the `Boolean` Property `UserLink` (`true`), qualified with its namespace of server-specific types, with a NodeId derived from the persisted link identifier; declared links carry none, and `RemoveLink` deletes it with the link. `DocumentationLinkRecord` gets the additive `IsUserLink`, which `ReadDocumentationLinksAsync` fills with one Browse over all links and one Read of the markers found (comparing the name only, as the namespace is configurable; `false` for a server that does not mark links). The name is shared as `DocumentationLinkProperties.UserLink`. `UA.slnx` and `.azurepipelines/expected-packages.txt` get the new projects and packages in the commit that adds them. All projects target `net48;net8.0;net9.0;net10.0` through the repository properties (`LibTargetFrameworks`, `TestsTargetFrameworks`, `AppTargetFrameworks`); `IsAotCompatible` and `PublishAot` are set from net10.0 on, as in the other companion projects. ## Tests - **`Opc.Ua.AMB.Tests`: 164 tests**, most of them against a hosted server over real client sessions: - the model: the SHA-256 of the NodeSet and the generator behaviours AMB is the first companion model to rely on; - each building block in-process and over a session: discovery through every category with NodeVersion and model change events, AssetId writes, health alarms and maintenance activities through all transitions with acknowledgement, AddLink and RemoveLink, locations and structure, the advertised conformance units and the facet; - a restart with the file system stores (links and AssetId survive); - the `UserLink` marker: present on an added link only, the same NodeId after a restart, removed with `RemoveLink`, and `IsUserLink` on the client in the snapshot, after `AddLink`, after a restart and after `RemoveLink`; - composition with `AddOpcUaDi` (both startup orders), `AddPumps` and `AddMachinery`; - `AmbClient` end to end against a hosted server, including a Machinery machine as an asset. - **Results (macOS, local):** 164 passed / 0 failed on net10.0 and on net8.0. .NET Framework is covered by the fork CI's Windows legs. - **Coverage** of the three AMB assemblies (coverlet with `tests/coverlet.runsettings.xml`, net10.0, generated `*.g.cs`, `*TypeClient` proxies and `*Log` classes excluded): lines 93.9 % (4890 of 5206), branches 84.0 % (1294 of 1539); model 100 %, client 94.5 %, server 93.4 %. - **Build:** the three packages, the tests and the two samples build without warnings for net48, net8.0, net9.0 and net10.0; `UA.slnx` builds in Release (its warnings are all in files this PR does not touch, as on master). `dotnet format` (whitespace, style, analyzers with `--severity info`) reports nothing on the 95 changed C# files. - **Samples:** `AmbServer` and `AmbClient` ran against each other on net10.0: the client discovered the three assets, printed their snapshots and the location hierarchies, added and removed a documentation link and streamed alarms and maintenance. - Fork CI (full matrix incl. macOS) and CodeQL: green on the same five package commits before the rebase onto the current #4650 head — [CI run 37144486107](https://github.com/biancode/UA-.NETStandard/actions/runs/37144486107), [CodeQL run 37144487873](https://github.com/biancode/UA-.NETStandard/actions/runs/37144487873). The run on the current head (rebased, plus the user-link commit; AMB tests 164/164 locally on net10.0) is in progress: [CI run 37203517457](https://github.com/biancode/UA-.NETStandard/actions/runs/37203517457). ## Notes for review - **Size.** About 26.8k lines, larger than each of the companion PRs #4577 to #4580. Without changes to the code it splits into the model, server and server tests, and the client, end-to-end tests, samples and docs on top; the issue asks which you prefer. - **Interfaces on types.** §9.4.1 and §12.1 ask for alarm and condition types that implement `IRootCauseIndicationType` and `IMaintenanceEventType`. The server creates subtypes of the DI alarm types in its own type namespace (`urn:opcua-netstandard:amb:types`); `UseServerDefinedAlarmTypes = false` uses `HasInterface` on the instance instead, and then does not report the units that need the type-level interfaces. - **Conformance unit names** follow Tables 54 to 56; the profile database has no AMB entries yet. The QualifiedNames are in namespace 0, as DI and Machinery publish theirs (question 5 in the issue). - **Generator gap.** Children a NodeSet gives to a top-level instance (here the `NodeVersion` properties of the alias categories) get no identifier constants. The fix changes the identifiers of other generated models, the reference server's among them, so it is not part of this PR; `AmbModelTests.NodeVersionHasNoIdentifierConstant` records it and fails once the generator emits the constants. - **net48.** The code that the SDK so far only built for .NET 8 and later uses the `NET5_0_OR_GREATER` pattern of `JsonEncoder` for `Enum.IsDefined` and API forms that .NET Framework has (`string.Format`, `Guid.ToString(format, provider)`); the tests use a small async file helper instead of `File.WriteAllTextAsync`. - **User links.** The `UserLink` Property is metadata of this server, not an AMB name: commit 6 explains why and how a client uses it. Changed lines of that commit covered: 69/70 (the one line left is the loop continuation for a link that has other Properties but no marker). - **Known limitations** are listed in the guide: the `DeviceHealth` SourceTimestamp across a restart, removing optional maintenance members once set, `PatchIdentifiers`, locations only at the deepest level, and the history units. ## Related Issues - Part of #4649 - Stacked on #4650 ## 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. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. (The AMB suite on net10.0 and net8.0 locally on macOS; .NET Framework is covered by the fork CI's Windows net48 legs.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
575 lines
25 KiB
C#
575 lines
25 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.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.AMB.Server;
|
|
using Opc.Ua.AMB.Server.Assets;
|
|
using Opc.Ua.AMB.Server.Configuration;
|
|
using Opc.Ua.Di;
|
|
using Opc.Ua.Di.Server;
|
|
using Opc.Ua.Di.Server.Builders;
|
|
using Opc.Ua.Server.Fluent;
|
|
|
|
namespace Opc.Ua.AMB.Tests
|
|
{
|
|
/// <summary>
|
|
/// Tests the writable, persistent <c>AssetId</c> ("AMB Configurable
|
|
/// Asset Identification") through the server's Write service.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("AMB")]
|
|
[Category("Hosting")]
|
|
[NonParallelizable]
|
|
public sealed class ConfigurableAssetIdTests
|
|
{
|
|
private const string SensorUri = "urn:acme:sensor:4711";
|
|
|
|
[Test]
|
|
public async Task ADeviceGetsTheAssetIdItsTypeDeclaresAsync()
|
|
{
|
|
var store = new MemoryAssetConfigurationStore();
|
|
DeviceState? device = null;
|
|
IAssetHandle? handle = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(ADeviceGetsTheAssetIdItsTypeDeclaresAsync),
|
|
store,
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(
|
|
assets,
|
|
asset => asset.WithConfigurableAssetId("Sensor-1"));
|
|
},
|
|
h => handle = h).ConfigureAwait(false);
|
|
|
|
Assert.That(device!.AssetId, Is.Not.Null, "the slot DeviceType declares is used");
|
|
NodeId assetId = device.AssetId!.NodeId;
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(device.AssetId.Value, Is.EqualTo("Sensor-1"));
|
|
Assert.That(device.AssetId.AccessLevel & AccessLevels.CurrentWrite, Is.Not.Zero);
|
|
Assert.That(device.AssetId.UserAccessLevel & AccessLevels.CurrentWrite, Is.Not.Zero);
|
|
Assert.That(
|
|
device.ReferenceExists(
|
|
Ua.ReferenceTypeIds.HasInterface,
|
|
false,
|
|
server.ToNodeId(Opc.Ua.Di.ObjectTypeIds.ITagNameplateType)),
|
|
Is.False,
|
|
"DeviceType implements ITagNameplateType already");
|
|
Assert.That(handle!.IsAssetIdConfigurable, Is.True);
|
|
Assert.That(handle.AssetId, Is.EqualTo("Sensor-1"));
|
|
});
|
|
|
|
StatusCode written = await server.WriteAsync(assetId, Variant.From("Pump-7")).ConfigureAwait(false);
|
|
DataValue read = await server.ReadAsync(assetId).ConfigureAwait(false);
|
|
string? persisted = await store.GetValueAsync(SensorUri, AssetConfigurationNames.AssetId)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(StatusCode.IsGood(written), Is.True, written.ToString());
|
|
Assert.That(read.WrappedValue.TryGetValue(out string value), Is.True);
|
|
Assert.That(value, Is.EqualTo("Pump-7"));
|
|
Assert.That(persisted, Is.EqualTo("Pump-7"));
|
|
Assert.That(handle!.AssetId, Is.EqualTo("Pump-7"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task TheAssetIdOfTheSlotIsAPropertyClientsCanBrowseToAsync()
|
|
{
|
|
DeviceState? device = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(TheAssetIdOfTheSlotIsAPropertyClientsCanBrowseToAsync),
|
|
new MemoryAssetConfigurationStore(),
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(
|
|
assets,
|
|
asset => asset.WithConfigurableAssetId("Sensor-1"));
|
|
},
|
|
_ => { }).ConfigureAwait(false);
|
|
|
|
NodeId assetId = device!.AssetId!.NodeId;
|
|
IReadOnlyList<ReferenceDescription> properties = await server
|
|
.BrowseAsync(device.NodeId, Ua.ReferenceTypeIds.HasProperty)
|
|
.ConfigureAwait(false);
|
|
DataValue dataType = await server.ReadAsync(assetId, Attributes.DataType).ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(
|
|
properties.Select(reference => reference.NodeId),
|
|
Does.Contain((ExpandedNodeId)assetId),
|
|
"browsing the device lists the AssetId");
|
|
Assert.That(
|
|
properties.Single(reference => reference.NodeId == (ExpandedNodeId)assetId).TypeDefinition,
|
|
Is.EqualTo((ExpandedNodeId)Ua.VariableTypeIds.PropertyType));
|
|
Assert.That(
|
|
dataType.WrappedValue.TryGetValue(out NodeId type) ? type : NodeId.Null,
|
|
Is.EqualTo(Ua.DataTypeIds.String));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task TheAssetIdIsLimitedInLengthAndTypeAsync()
|
|
{
|
|
var store = new MemoryAssetConfigurationStore();
|
|
DeviceState? device = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(TheAssetIdIsLimitedInLengthAndTypeAsync),
|
|
store,
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId());
|
|
},
|
|
configure: options => options.MaxAssetIdLength = 40).ConfigureAwait(false);
|
|
NodeId assetId = device!.AssetId!.NodeId;
|
|
|
|
StatusCode longest = await server.WriteAsync(assetId, Variant.From(new string('x', 40)))
|
|
.ConfigureAwait(false);
|
|
StatusCode tooLong = await server.WriteAsync(assetId, Variant.From(new string('y', 41)))
|
|
.ConfigureAwait(false);
|
|
StatusCode wrongType = await server.WriteAsync(assetId, Variant.From(42)).ConfigureAwait(false);
|
|
string? persisted = await store.GetValueAsync(SensorUri, AssetConfigurationNames.AssetId)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(StatusCode.IsGood(longest), Is.True, longest.ToString());
|
|
Assert.That(tooLong.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
|
|
Assert.That(wrongType.Code, Is.EqualTo(StatusCodes.BadTypeMismatch));
|
|
Assert.That(persisted, Is.EqualTo(new string('x', 40)));
|
|
Assert.That(device.AssetId.Value, Is.EqualTo(new string('x', 40)));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task APersistedValueWinsOverTheDefaultAsync()
|
|
{
|
|
var store = new MemoryAssetConfigurationStore();
|
|
await store.SetValueAsync(SensorUri, AssetConfigurationNames.AssetId, "Persisted").ConfigureAwait(false);
|
|
DeviceState? device = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(APersistedValueWinsOverTheDefaultAsync),
|
|
store,
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(
|
|
assets,
|
|
asset => asset.WithConfigurableAssetId("Default"));
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(device!.AssetId!.Value, Is.EqualTo("Persisted"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task TheConfiguredValueSurvivesARestartAsync()
|
|
{
|
|
string directory = Path.Combine(
|
|
TestContext.CurrentContext.WorkDirectory,
|
|
nameof(TheConfiguredValueSurvivesARestartAsync),
|
|
Guid.NewGuid().ToString("N"));
|
|
|
|
DeviceState? first = null;
|
|
await using (AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(TheConfiguredValueSurvivesARestartAsync) + "1",
|
|
store: null,
|
|
(builder, assets) =>
|
|
{
|
|
first = builder.Device;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId("Initial"));
|
|
},
|
|
configure: options => options.UseFileSystemStores(directory)).ConfigureAwait(false))
|
|
{
|
|
StatusCode written = await server.WriteAsync(first!.AssetId!.NodeId, Variant.From("Configured"))
|
|
.ConfigureAwait(false);
|
|
Assert.That(StatusCode.IsGood(written), Is.True, written.ToString());
|
|
Assert.That(
|
|
server.Manager.ConformanceUnits.ToArray(),
|
|
Does.Contain(new QualifiedName("AMB Configurable Asset Identification")));
|
|
}
|
|
|
|
DeviceState? second = null;
|
|
await using (AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(TheConfiguredValueSurvivesARestartAsync) + "2",
|
|
store: null,
|
|
(builder, assets) =>
|
|
{
|
|
second = builder.Device;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId("Initial"));
|
|
},
|
|
configure: options => options.UseFileSystemStores(directory)).ConfigureAwait(false))
|
|
{
|
|
Assert.That(second!.AssetId!.Value, Is.EqualTo("Configured"));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task ExistingWriteHandlersKeepRunningAsync()
|
|
{
|
|
var store = new MemoryAssetConfigurationStore();
|
|
DeviceState? device = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(ExistingWriteHandlersKeepRunningAsync),
|
|
store,
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
device.AddAssetId(builder.Context);
|
|
builder.Manager.AddNode(device.AssetId!);
|
|
device.AssetId!.OnSimpleWriteValue = (ISystemContext _, NodeState _, ref Variant value) =>
|
|
value.TryGetValue(out string text) && text == "forbidden"
|
|
? StatusCodes.BadUserAccessDenied
|
|
: ServiceResult.Good;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId());
|
|
}).ConfigureAwait(false);
|
|
NodeId assetId = device!.AssetId!.NodeId;
|
|
|
|
StatusCode refused = await server.WriteAsync(assetId, Variant.From("forbidden")).ConfigureAwait(false);
|
|
string? afterRefusal = await store.GetValueAsync(SensorUri, AssetConfigurationNames.AssetId)
|
|
.ConfigureAwait(false);
|
|
StatusCode accepted = await server.WriteAsync(assetId, Variant.From("allowed")).ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(refused.Code, Is.EqualTo(StatusCodes.BadUserAccessDenied));
|
|
Assert.That(afterRefusal, Is.Null, "a refused value is not persisted");
|
|
Assert.That(StatusCode.IsGood(accepted), Is.True, accepted.ToString());
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task AValueThatCannotBePersistedIsRefusedAsync()
|
|
{
|
|
DeviceState? device = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(AValueThatCannotBePersistedIsRefusedAsync),
|
|
new FailingStore(),
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId("Kept"));
|
|
}).ConfigureAwait(false);
|
|
|
|
StatusCode refused = await server.WriteAsync(device!.AssetId!.NodeId, Variant.From("Lost"))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(refused.Code, Is.EqualTo(StatusCodes.BadResourceUnavailable));
|
|
Assert.That(device.AssetId.Value, Is.EqualTo("Kept"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task UnregisteringRestoresTheWriteHandlersAsync()
|
|
{
|
|
DeviceState? device = null;
|
|
IAssetHandle? handle = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(UnregisteringRestoresTheWriteHandlersAsync),
|
|
new MemoryAssetConfigurationStore(),
|
|
(builder, assets) =>
|
|
{
|
|
device = builder.Device;
|
|
return builder.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId());
|
|
},
|
|
h => handle = h).ConfigureAwait(false);
|
|
Assert.That(device!.AssetId!.OnSimpleWriteValueAsync, Is.Not.Null);
|
|
|
|
await handle!.UnregisterAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(device.AssetId.OnSimpleWriteValueAsync, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ADefaultLongerThanTheMaximumIsRefusedAsync()
|
|
{
|
|
ServiceResultException? refused = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(ADefaultLongerThanTheMaximumIsRefusedAsync),
|
|
new MemoryAssetConfigurationStore(),
|
|
async (builder, assets) =>
|
|
{
|
|
try
|
|
{
|
|
return await builder.RegisterAsAssetAsync(
|
|
assets,
|
|
asset => asset.WithConfigurableAssetId(new string('z', 41))).ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
refused = ex;
|
|
return null!;
|
|
}
|
|
},
|
|
configure: options => options.MaxAssetIdLength = 40).ConfigureAwait(false);
|
|
|
|
Assert.That(refused?.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
|
|
}
|
|
|
|
[Test]
|
|
public async Task AnObjectWithoutADeclaredAssetIdGetsTheInterfaceAsync()
|
|
{
|
|
// An object whose type is not a DI component has no AssetId slot:
|
|
// the property is added and ITagNameplateType applied to the
|
|
// instance (OPC 10000-110 §7).
|
|
IAssetHandle? handle = null;
|
|
BaseObjectState? machine = null;
|
|
await using AmbHostedServer server = await AmbHostedServer.StartAsync(
|
|
nameof(AnObjectWithoutADeclaredAssetIdGetsTheInterfaceAsync),
|
|
builder => builder
|
|
.AddOpcUaDi()
|
|
.AddAssetManagement()
|
|
.ConfigureAssetManagement(async context =>
|
|
{
|
|
INodeBuilder<BaseObjectState> press = AssetRegistrationTests.CreateMachine(
|
|
context,
|
|
"urn:acme:press:1");
|
|
machine = press.Node;
|
|
handle = await context.Assets.RegisterAssetAsync(
|
|
press,
|
|
asset => asset.WithConfigurableAssetId("Press-1"),
|
|
context.CancellationToken).ConfigureAwait(false);
|
|
})).ConfigureAwait(false);
|
|
|
|
ushort di = (ushort)server.Server.NamespaceUris.GetIndex(Opc.Ua.Di.Namespaces.OpcUaDi);
|
|
BaseInstanceState? property = machine!.FindChildWithQualifiedName(
|
|
server.Manager.SystemContext,
|
|
new QualifiedName("AssetId", di));
|
|
Assert.That(property, Is.InstanceOf<BaseVariableState>());
|
|
|
|
StatusCode written = await server.WriteAsync(property!.NodeId, Variant.From("Press-2"))
|
|
.ConfigureAwait(false);
|
|
IReadOnlyList<ReferenceDescription> interfaces = await server.BrowseAsync(
|
|
machine.NodeId,
|
|
Ua.ReferenceTypeIds.HasInterface).ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(StatusCode.IsGood(written), Is.True, written.ToString());
|
|
Assert.That(handle!.AssetId, Is.EqualTo("Press-2"));
|
|
Assert.That(
|
|
interfaces.Select(reference => server.ToNodeId(reference.NodeId)),
|
|
Does.Contain(server.ToNodeId(Opc.Ua.Di.ObjectTypeIds.ITagNameplateType)));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task WritesOfTheAssetIdAreAppliedInTheOrderTheyArriveAsync()
|
|
{
|
|
var store = new GatedStore("RACE-A");
|
|
IAssetHandle? handle = null;
|
|
DeviceState? sensor = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(WritesOfTheAssetIdAreAppliedInTheOrderTheyArriveAsync),
|
|
store,
|
|
(device, assets) =>
|
|
{
|
|
sensor = device.Device;
|
|
return device.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId("RACE-0"));
|
|
},
|
|
registered => handle = registered).ConfigureAwait(false);
|
|
NodeId assetId = sensor!.AssetId!.NodeId;
|
|
|
|
Task<StatusCode> first = server.WriteAsync(assetId, Variant.From("RACE-A"));
|
|
await store.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
|
Task<StatusCode> second = server.WriteAsync(assetId, Variant.From("RACE-B"));
|
|
await Task.Delay(300).ConfigureAwait(false);
|
|
bool secondWaited = !second.IsCompleted;
|
|
store.Open();
|
|
StatusCode firstResult = await first.ConfigureAwait(false);
|
|
StatusCode secondResult = await second.ConfigureAwait(false);
|
|
|
|
ArrayOf<AliasNameDataType> byLast = await server
|
|
.FindAliasAsync(ObjectIds.AssetsByAssetId, "RACE-B").ConfigureAwait(false);
|
|
ArrayOf<AliasNameDataType> byFirst = await server
|
|
.FindAliasAsync(ObjectIds.AssetsByAssetId, "RACE-A").ConfigureAwait(false);
|
|
string? stored = await store.GetValueAsync(SensorUri, AssetConfigurationNames.AssetId)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(StatusCode.IsGood(firstResult), Is.True, firstResult.ToString());
|
|
Assert.That(StatusCode.IsGood(secondResult), Is.True, secondResult.ToString());
|
|
Assert.That(secondWaited, Is.True, "the second write waits for the first");
|
|
Assert.That(handle!.AssetId, Is.EqualTo("RACE-B"));
|
|
Assert.That(stored, Is.EqualTo("RACE-B"));
|
|
Assert.That(byLast.Count, Is.EqualTo(1));
|
|
Assert.That(byFirst.Count, Is.Zero);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task TheLengthOfAnAssetIdIsCountedInUnicodeCharactersAsync()
|
|
{
|
|
DeviceState? sensor = null;
|
|
await using AmbHostedServer server = await StartWithDeviceAsync(
|
|
nameof(TheLengthOfAnAssetIdIsCountedInUnicodeCharactersAsync),
|
|
null,
|
|
(device, assets) =>
|
|
{
|
|
sensor = device.Device;
|
|
return device.RegisterAsAssetAsync(assets, asset => asset.WithConfigurableAssetId());
|
|
},
|
|
configure: options => options.MaxAssetIdLength = AmbServerOptions.MinimumAssetIdLength)
|
|
.ConfigureAwait(false);
|
|
NodeId assetId = sensor!.AssetId!.NodeId;
|
|
string fits = string.Concat(Enumerable.Repeat("\U0001F527", AmbServerOptions.MinimumAssetIdLength));
|
|
|
|
StatusCode accepted = await server.WriteAsync(assetId, Variant.From(fits)).ConfigureAwait(false);
|
|
StatusCode refused = await server.WriteAsync(assetId, Variant.From(fits + "x")).ConfigureAwait(false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(fits, Has.Length.EqualTo(2 * AmbServerOptions.MinimumAssetIdLength));
|
|
Assert.That(StatusCode.IsGood(accepted), Is.True, "40 characters outside the BMP: " + accepted);
|
|
Assert.That(refused.Code, Is.EqualTo(StatusCodes.BadOutOfRange));
|
|
});
|
|
}
|
|
|
|
private static Task<AmbHostedServer> StartWithDeviceAsync(
|
|
string name,
|
|
IAssetConfigurationStore? store,
|
|
Func<IDeviceBuilder<DeviceState>, IAssetManagement, ValueTask<IAssetHandle>> register,
|
|
Action<IAssetHandle>? registered = null,
|
|
Action<AmbServerOptions>? configure = null)
|
|
{
|
|
return AmbHostedServer.StartAsync(name, builder =>
|
|
{
|
|
if (store != null)
|
|
{
|
|
builder.Services.AddSingleton(store);
|
|
}
|
|
builder
|
|
.AddOpcUaDi()
|
|
.AddAssetManagement(configure)
|
|
.ConfigureDevicesFor<DiNodeManager>(async context =>
|
|
{
|
|
IDeviceBuilder<DeviceState> device = await AssetRegistrationTests.CreateDeviceAsync(
|
|
context,
|
|
"Sensor",
|
|
SensorUri).ConfigureAwait(false);
|
|
IAssetHandle handle = await register(
|
|
device,
|
|
context.GetRequiredService<IAssetManagement>()).ConfigureAwait(false);
|
|
registered?.Invoke(handle);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A store in memory that holds back the write of one value until it is
|
|
/// opened.
|
|
/// </summary>
|
|
private sealed class GatedStore : IAssetConfigurationStore
|
|
{
|
|
public GatedStore(string heldValue)
|
|
{
|
|
m_heldValue = heldValue;
|
|
}
|
|
|
|
public TaskCompletionSource<bool> Entered { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public bool IsPersistent => true;
|
|
|
|
public void Open()
|
|
{
|
|
m_open.TrySetResult(true);
|
|
}
|
|
|
|
public ValueTask<string?> GetValueAsync(
|
|
string productInstanceUri,
|
|
string name,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return m_inner.GetValueAsync(productInstanceUri, name, cancellationToken);
|
|
}
|
|
|
|
public async ValueTask SetValueAsync(
|
|
string productInstanceUri,
|
|
string name,
|
|
string? value,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.Equals(value, m_heldValue, StringComparison.Ordinal))
|
|
{
|
|
Entered.TrySetResult(true);
|
|
await m_open.Task.ConfigureAwait(false);
|
|
}
|
|
await m_inner.SetValueAsync(productInstanceUri, name, value, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private readonly string m_heldValue;
|
|
|
|
private readonly TaskCompletionSource<bool> m_open =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private readonly MemoryAssetConfigurationStore m_inner = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A store whose writes fail.
|
|
/// </summary>
|
|
private sealed class FailingStore : IAssetConfigurationStore
|
|
{
|
|
public bool IsPersistent => true;
|
|
|
|
public ValueTask<string?> GetValueAsync(
|
|
string productInstanceUri,
|
|
string name,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return new ValueTask<string?>((string?)null);
|
|
}
|
|
|
|
public ValueTask SetValueAsync(
|
|
string productInstanceUri,
|
|
string name,
|
|
string? value,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new IOException("disk full");
|
|
}
|
|
}
|
|
}
|
|
}
|