/* ========================================================================
* 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.Client;
using Opc.Ua.AMB.Server.Assets;
using Opc.Ua.AMB.Server.Health;
using Opc.Ua.AMB.Server.Maintenance;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Di;
using Opc.Ua.Di.Server;
using Opc.Ua.Di.Server.Builders;
using Opc.Ua.Machinery.Server;
using Opc.Ua.Machinery.Server.Builders;
using Opc.Ua.Tests;
namespace Opc.Ua.AMB.Tests
{
///
/// Drives a hosted Asset Management Basics server with the AMB client
/// over real sessions: discovery, identification, the AssetId move,
/// health alarms, maintenance, documentation links, locations and
/// structure, a restart with persistence, and a Machinery machine as an
/// asset.
///
[TestFixture]
[Category("AMB")]
[Category("Hosting")]
[NonParallelizable]
public sealed class AmbClientServerE2eTests
{
private const string PumpUri = "urn:acme:pump:1";
private const string MotorUri = "urn:acme:motor:1";
private const string SensorUri = "urn:acme:sensor:7";
private static readonly ExpandedNodeId s_eclass = new("0173-1#01-ADN228#012", "urn:example:eclass");
private static readonly DateTimeUtc s_planned = new(new DateTime(2027, 3, 1, 8, 0, 0, DateTimeKind.Utc));
private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30);
private static readonly string[] s_levels = ["Plant1", "Plant1/Hall3", "Plant1/Hall3/Line2"];
[Test]
public async Task TheClientDiscoversAndReadsThePlantAsync()
{
await using Plant plant = await Plant.StartAsync(nameof(TheClientDiscoversAndReadsThePlantAsync), null)
.ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(plant.Server, "discovery")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
ArrayOf assets = await amb.DiscoverAssetsAsync().ConfigureAwait(false);
ArrayOf byUri = await amb
.FindAssetsAsync("urn:acme:pump:%", AssetAliasCategory.ByProductInstanceUri)
.ConfigureAwait(false);
ArrayOf byAssetId = await amb.FindAssetsAsync("P-1", AssetAliasCategory.ByAssetId)
.ConfigureAwait(false);
var browsed = new List();
await foreach (AssetAlias alias in amb
.EnumerateAssetsAsync(AssetAliasCategory.Assets)
.ConfigureAwait(false))
{
browsed.Add(alias);
}
NodeId pump = plant.Pump!.NodeId;
AssetSnapshot snapshot = await amb.ReadAssetAsync(pump).ConfigureAwait(false);
ArrayOf hierarchy = await amb.BrowseLocationsAsync(AssetLocationKind.Hierarchical)
.ConfigureAwait(false);
ArrayOf requirements = await amb
.ReadEntriesAsync(pump, AssetEntryFolder.Requirements)
.ConfigureAwait(false);
var subAssets = new List();
await foreach (ExpandedNodeId sub in amb.EnumerateSubAssetsAsync(pump).ConfigureAwait(false))
{
subAssets.Add(sub);
}
ArrayOf pumpRelations = await amb.ReadRelationsAsync(pump).ConfigureAwait(false);
ArrayOf sensorRelations = await amb.ReadRelationsAsync(plant.Sensor!.NodeId)
.ConfigureAwait(false);
DataValue profiles = await ReadAsync(
connection.Session,
Ua.VariableIds.Server_ServerCapabilities_ServerProfileArray).ConfigureAwait(false);
DataValue units = await ReadAsync(
connection.Session,
Ua.VariableIds.Server_ServerCapabilities_ConformanceUnits).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(amb.IsSupported, Is.True);
Assert.That(
assets.ToArray(),
Is.EquivalentTo(new[] { plant.Motor!.NodeId, pump, plant.Sensor.NodeId }));
Assert.That(byUri.Count, Is.EqualTo(1));
Assert.That(byUri[0].Name, Is.EqualTo(PumpUri));
Assert.That(byAssetId.Count, Is.EqualTo(1));
Assert.That(
browsed.Count(alias => alias.Category == AssetAliasCategory.ByProductInstanceUri),
Is.EqualTo(3));
Assert.That(
browsed.Count(alias => alias.Category == AssetAliasCategory.ByAssetId),
Is.EqualTo(1),
"§8.2.3: the motor and the sensor have no AssetId and are not listed by it");
Assert.That(snapshot.Identification.ProductInstanceUri, Is.EqualTo(PumpUri));
Assert.That(snapshot.Identification.AssetId, Is.EqualTo("P-1"));
Assert.That(snapshot.Identification.HardwareRevision, Is.EqualTo("HW-1"));
Assert.That(snapshot.Identification.SoftwareRevision, Is.EqualTo("SW-1"));
Assert.That(snapshot.Identification.RevisionCounter, Is.EqualTo(1));
Assert.That(snapshot.DeviceHealth, Is.EqualTo(DeviceHealthEnumeration.NORMAL));
Assert.That(snapshot.HealthAlarms.Count, Is.EqualTo(1));
Assert.That(snapshot.HealthAlarms[0].ConditionName, Is.EqualTo("Fieldbus"));
Assert.That(snapshot.HealthAlarms[0].ConditionClass, Is.SameAs(AmbConditionClass.ConnectionFailure));
Assert.That(snapshot.HealthAlarms[0].IsActive, Is.False);
Assert.That(snapshot.HealthAlarms[0].PotentialRootCauses.Count, Is.EqualTo(1));
Assert.That(snapshot.MaintenanceActivities.Count, Is.EqualTo(1));
Assert.That(snapshot.MaintenanceActivities[0].State, Is.EqualTo(MaintenanceStateKind.Planned));
Assert.That(snapshot.MaintenanceActivities[0].PlannedDate, Is.EqualTo(s_planned));
Assert.That(snapshot.MaintenanceActivities[0].ConditionClass, Is.SameAs(AmbConditionClass.Inspection));
Assert.That(snapshot.DocumentationLinks.Count, Is.EqualTo(2));
Assert.That(
ListOf(snapshot.DocumentationLinks).Single(link => link.BrowseName.Name == "Manual").IsWritable,
Is.False);
Assert.That(
ListOf(snapshot.DocumentationLinks).Single(link => link.BrowseName.Name == "Handbook").IsWritable,
Is.True);
Assert.That(
ListOf(snapshot.DocumentationLinks).Select(link => link.IsUserLink),
Has.All.False,
"the manufacturer declared both links, so RemoveLink accepts neither");
Assert.That(snapshot.Context.HierarchicalLocation, Is.EqualTo("Plant1/Hall3/Line2"));
Assert.That(snapshot.Context.LocalTime?.Offset, Is.EqualTo((short)60));
Assert.That(snapshot.Context.Classifications.ToArray(), Does.Contain(s_eclass));
Assert.That(snapshot.Locations.Count, Is.EqualTo(1));
Assert.That(
ListOf(hierarchy).Select(location => location.Path),
Is.EqualTo(s_levels));
Assert.That(hierarchy[2].Assets.ToArray(), Does.Contain((ExpandedNodeId)pump));
Assert.That(snapshot.Locations[0].Location, Is.EqualTo(hierarchy[2].NodeId));
Assert.That(requirements.Count, Is.EqualTo(1));
Assert.That(requirements[0].Value.TryGetValue(out double voltage) ? voltage : 0, Is.EqualTo(24.0));
Assert.That(subAssets, Is.EqualTo(new[] { (ExpandedNodeId)plant.Motor.NodeId }));
Assert.That(
ListOf(sensorRelations).Select(relation => relation.ReferenceTypeId),
Does.Contain(Ua.ReferenceTypeIds.Utilizes));
Assert.That(
ListOf(pumpRelations).Any(relation =>
relation.IsInverse && relation.Target == (ExpandedNodeId)plant.Sensor.NodeId),
Is.True,
"the pump sees the inverse Utilizes of the sensor");
Assert.That(
profiles.WrappedValue.TryGetValue(out ArrayOf profileUris) ? profileUris.ToArray() : [],
Does.Contain("http://opcfoundation.org/UA-Profile/AMB/Server/BaseServer"));
Assert.That(
units.WrappedValue.TryGetValue(out ArrayOf unitNames) ? unitNames.ToArray() : [],
Is.SupersetOf(new[]
{
new QualifiedName("AMB Asset Identification"),
new QualifiedName("AMB Asset Discovery by AssetId"),
new QualifiedName("AMB Hierarchical Location Objects"),
new QualifiedName("AMB Sub-assets"),
new QualifiedName("AMB Asset relations")
}));
});
}
[Test]
public async Task WritingTheAssetIdMovesTheAssetAsync()
{
await using Plant plant = await Plant.StartAsync(nameof(WritingTheAssetIdMovesTheAssetAsync), null)
.ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(plant.Server, "assetid")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
NodeId pump = plant.Pump!.NodeId;
string? before = await amb.ReadNodeVersionAsync(AssetAliasCategory.ByAssetId).ConfigureAwait(false);
int writes = 0;
string written = string.Empty;
using var cts = new CancellationTokenSource(s_timeout);
await using IAsyncEnumerator changes = amb
.ObserveAssetSetChangesAsync(cancellationToken: cts.Token)
.GetAsyncEnumerator(cts.Token);
AssetSetChange change = await NextAsync(
changes,
async () =>
{
written = "P-" + (++writes + 1);
await amb.WriteAssetIdAsync(pump, written, cts.Token).ConfigureAwait(false);
},
candidate => candidate.Category == AssetAliasCategory.ByAssetId,
cts.Token).ConfigureAwait(false);
string? after = await amb.ReadNodeVersionAsync(AssetAliasCategory.ByAssetId).ConfigureAwait(false);
ArrayOf moved = await amb.FindAssetsAsync(written, AssetAliasCategory.ByAssetId)
.ConfigureAwait(false);
ArrayOf old = await amb.FindAssetsAsync("P-1", AssetAliasCategory.ByAssetId)
.ConfigureAwait(false);
AssetIdentificationRecord identification = await amb.ReadIdentificationAsync(pump).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(change.Category, Is.EqualTo(AssetAliasCategory.ByAssetId));
Assert.That(after, Is.Not.EqualTo(before));
Assert.That(moved.Count, Is.EqualTo(1));
Assert.That(old.Count, Is.Zero);
Assert.That(identification.AssetId, Is.EqualTo(written));
});
ServiceResultException tooLong = Assert.ThrowsAsync(
async () => await amb.WriteAssetIdAsync(pump, new string('x', 300)).ConfigureAwait(false))!;
ServiceResultException none = Assert.ThrowsAsync(
async () => await amb.WriteAssetIdAsync(plant.Motor!.NodeId, "M-1").ConfigureAwait(false))!;
Assert.Multiple(() =>
{
Assert.That(tooLong.StatusCode, Is.EqualTo(StatusCodes.BadOutOfRange));
Assert.That(none.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown), "the motor has no AssetId");
});
}
[Test]
public async Task TheClientFollowsAndAcknowledgesAHealthAlarmAsync()
{
await using Plant plant = await Plant
.StartAsync(nameof(TheClientFollowsAndAcknowledgesAHealthAlarmAsync), null)
.ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(plant.Server, "alarms")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
IAssetHealthAlarm fieldbus = plant.Pump!.Health!.Alarms[0];
var cable = new NodeId("Cable", plant.Server.Manager.InstanceNamespaceIndex);
using var cts = new CancellationTokenSource(s_timeout);
await using IAsyncEnumerator alarms = amb
.ObserveHealthAlarmsAsync(cancellationToken: cts.Token)
.GetAsyncEnumerator(cts.Token);
AssetAlarmRecord raised = await NextAsync(
alarms,
async () => await fieldbus.RaiseAsync(
700,
new LocalizedText("The fieldbus is down."),
[AssetRootCauses.Of(cable, new LocalizedText("Cable cut"))],
cts.Token).ConfigureAwait(false),
record => record.IsActive,
cts.Token).ConfigureAwait(false);
DeviceHealthEnumeration? derived = await amb.ReadDeviceHealthAsync(plant.Pump.NodeId).ConfigureAwait(false);
await amb.AcknowledgeAsync(raised.ConditionId, raised.EventId, new LocalizedText("seen"), cts.Token)
.ConfigureAwait(false);
AssetAlarmRecord acknowledged = await NextAsync(
alarms,
() => Task.CompletedTask,
record => record.IsAcknowledged,
cts.Token).ConfigureAwait(false);
await fieldbus.ClearAsync(cancellationToken: cts.Token).ConfigureAwait(false);
AssetAlarmRecord cleared = await NextAsync(
alarms,
() => Task.CompletedTask,
record => !record.IsActive,
cts.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(raised.SourceNode, Is.EqualTo(plant.Pump.NodeId));
Assert.That(raised.SourceName, Is.EqualTo("Pump"));
Assert.That(raised.Severity, Is.EqualTo((ushort)700));
Assert.That(raised.FaultCategory, Is.EqualTo(AssetFaultSeverity.MajorRecoverableFault));
Assert.That(raised.ConditionClass, Is.SameAs(AmbConditionClass.ConnectionFailure));
Assert.That(raised.Message.Text, Is.EqualTo("The fieldbus is down."));
Assert.That(raised.PotentialRootCauses.Count, Is.EqualTo(1));
Assert.That(raised.PotentialRootCauses[0].RootCauseId, Is.EqualTo(cable));
Assert.That(
raised.EventType,
Is.EqualTo(new NodeId("AssetFailureAlarmType", plant.Server.Manager.TypeNamespaceIndex)));
Assert.That(derived, Is.EqualTo(DeviceHealthEnumeration.FAILURE));
Assert.That(acknowledged.IsActive, Is.True);
Assert.That(cleared.Retain, Is.False, "acknowledged and cleared");
});
}
[Test]
public async Task AConditionRefreshFindsTheConditionsOfAnAssetAsync()
{
await using Plant plant = await Plant
.StartAsync(nameof(AConditionRefreshFindsTheConditionsOfAnAssetAsync), null)
.ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(plant.Server, "refresh")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
IAssetHandle pump = plant.Pump!;
IAssetHealthAlarm bearing = pump.Health!.Alarms[0];
await bearing.RaiseAsync(450, new LocalizedText("Bearing hot")).ConfigureAwait(false);
List refreshed = await amb.RefreshConditionsAsync(pump.NodeId, CancellationToken.None)
.ConfigureAwait(false);
HashSet classes = await amb.MaintenanceClassesAsync(CancellationToken.None).ConfigureAwait(false);
AssetAlarmRecord[] alarms =
[
.. refreshed
.Where(values => !AmbConditionFields.IsMaintenance(values, classes))
.Select(values => AmbConditionFields.ToAlarm(values, amb.Session.MessageContext))
];
MaintenanceActivityRecord[] activities =
[
.. refreshed
.Where(values => AmbConditionFields.IsMaintenance(values, classes))
.Select(values => AmbConditionFields.ToMaintenance(values, amb.Session.MessageContext))
];
Assert.Multiple(() =>
{
Assert.That(alarms.Select(alarm => alarm.ConditionName), Does.Contain(bearing.Name));
Assert.That(alarms.All(alarm => alarm.SourceNode == pump.NodeId), Is.True);
Assert.That(activities, Is.Not.Empty, "the planned activity is retained");
Assert.That(activities[0].State, Is.EqualTo(MaintenanceStateKind.Planned));
});
}
[Test]
public async Task TheClientFollowsAMaintenanceActivityAsync()
{
await using Plant plant = await Plant.StartAsync(nameof(TheClientFollowsAMaintenanceActivityAsync), null)
.ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(plant.Server, "maintenance")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
IMaintenanceActivity inspection = plant.Pump!.Maintenance!.Activities[0];
using var cts = new CancellationTokenSource(s_timeout);
await using IAsyncEnumerator activities = amb
.ObserveMaintenanceAsync(cancellationToken: cts.Token)
.GetAsyncEnumerator(cts.Token);
MaintenanceActivityRecord planned = await NextAsync(
activities,
async () => await inspection
.UpdateAsync(details => details.EstimatedDowntime = TimeSpan.FromHours(3), default, cts.Token)
.ConfigureAwait(false),
record => record.State == MaintenanceStateKind.Planned,
cts.Token).ConfigureAwait(false);
await inspection.StartAsync(cancellationToken: cts.Token).ConfigureAwait(false);
MaintenanceActivityRecord executing = await NextAsync(
activities,
() => Task.CompletedTask,
record => record.State == MaintenanceStateKind.Executing,
cts.Token).ConfigureAwait(false);
await inspection.FinishAsync(new LocalizedText("No findings."), cts.Token).ConfigureAwait(false);
MaintenanceActivityRecord finished = await NextAsync(
activities,
() => Task.CompletedTask,
record => record.State == MaintenanceStateKind.Finished,
cts.Token).ConfigureAwait(false);
await amb.AcknowledgeAsync(finished.ConditionId, finished.EventId, new LocalizedText("done"), cts.Token)
.ConfigureAwait(false);
MaintenanceActivityRecord acknowledged = await NextAsync(
activities,
() => Task.CompletedTask,
record => record.IsAcknowledged,
cts.Token).ConfigureAwait(false);
ArrayOf read = await amb.ReadMaintenanceActivitiesAsync(plant.Pump.NodeId)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(planned.ConditionName, Is.EqualTo("AnnualInspection"));
Assert.That(planned.PlannedDate, Is.EqualTo(s_planned));
Assert.That(planned.EstimatedDowntime, Is.EqualTo(TimeSpan.FromHours(3)));
Assert.That(planned.IsActive, Is.True);
Assert.That(executing.IsActive, Is.True);
Assert.That(finished.IsActive, Is.False);
Assert.That(finished.Message.Text, Is.EqualTo("No findings."));
Assert.That(finished.Retain, Is.True, "retained until acknowledged");
Assert.That(acknowledged.Retain, Is.False);
Assert.That(read[0].State, Is.EqualTo(MaintenanceStateKind.Finished));
Assert.That(read[0].IsAcknowledged, Is.True);
});
}
[Test]
public async Task LinksAndTheAssetIdSurviveARestartAsync()
{
string state = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
nameof(AmbClientServerE2eTests),
Guid.NewGuid().ToString("N"));
const string name = nameof(LinksAndTheAssetIdSurviveARestartAsync);
NodeId added;
await using (Plant first = await Plant.StartAsync(name, state).ConfigureAwait(false))
{
await using Connection connection = await Connection.OpenAsync(first.Server, "first")
.ConfigureAwait(false);
AmbClient amb = connection.Client;
NodeId pump = first.Pump!.NodeId;
added = await amb.AddDocumentationLinkAsync(
pump,
"https://plant.example/wiring",
new QualifiedName("Wiring", first.Server.Manager.InstanceNamespaceIndex),
new LocalizedText("Wiring diagram")).ConfigureAwait(false);
ArrayOf links = await amb.ReadDocumentationLinksAsync(pump)
.ConfigureAwait(false);
Assert.That(
ListOf(links).Where(link => link.IsUserLink).Select(link => link.NodeId),
Is.EqualTo([added]),
"only the link added through AddLink is a user link");
await amb.WriteDocumentationLinkAsync(
ListOf(links).Single(link => link.BrowseName.Name == "Handbook").NodeId,
"https://plant.example/handbook").ConfigureAwait(false);
await amb.WriteAssetIdAsync(pump, "P-42").ConfigureAwait(false);
ServiceResultException manual = Assert.ThrowsAsync(
async () => await amb.RemoveDocumentationLinkAsync(
pump,
ListOf(links).Single(link => link.BrowseName.Name == "Manual").NodeId).ConfigureAwait(false))!;
Assert.That(manual.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
await using Plant second = await Plant.StartAsync(name, state).ConfigureAwait(false);
await using Connection reconnected = await Connection.OpenAsync(second.Server, "second")
.ConfigureAwait(false);
AmbClient client = reconnected.Client;
NodeId restarted = second.Pump!.NodeId;
ArrayOf restored = await client.ReadDocumentationLinksAsync(restarted)
.ConfigureAwait(false);
ArrayOf byAssetId = await client.FindAssetsAsync("P-42", AssetAliasCategory.ByAssetId)
.ConfigureAwait(false);
await client.RemoveDocumentationLinkAsync(restarted, added).ConfigureAwait(false);
ArrayOf afterRemoval = await client.ReadDocumentationLinksAsync(restarted)
.ConfigureAwait(false);
ArrayOf withoutAddIn = await client
.ReadDocumentationLinksAsync(second.Motor!.NodeId)
.ConfigureAwait(false);
ServiceResultException noAddIn = Assert.ThrowsAsync(
async () => await client.AddDocumentationLinkAsync(
second.Motor!.NodeId,
"https://x",
new QualifiedName("X")).ConfigureAwait(false))!;
Assert.Multiple(() =>
{
Assert.That(
ListOf(restored).Single(link => link.NodeId == added).Uri,
Is.EqualTo("https://plant.example/wiring"),
"the added link keeps its NodeId");
Assert.That(
ListOf(restored).Single(link => link.NodeId == added).IsUserLink,
Is.True,
"and stays a user link");
Assert.That(ListOf(afterRemoval).Select(link => link.IsUserLink), Has.All.False);
Assert.That(withoutAddIn.Count, Is.Zero, "an asset without the AddIn has no links");
Assert.That(
ListOf(restored).Single(link => link.BrowseName.Name == "Handbook").Uri,
Is.EqualTo("https://plant.example/handbook"));
Assert.That(byAssetId.Count, Is.EqualTo(1));
Assert.That(afterRemoval.Count, Is.EqualTo(2));
Assert.That(noAddIn.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
});
}
[Test]
public async Task AMachineryMachineIsAnAssetAsync()
{
IMachineHandle? machine = null;
IAssetHandle? asset = null;
await using AmbHostedServer server = await AmbHostedServer.StartAsync(
nameof(AMachineryMachineIsAnAssetAsync),
builder => builder
.AddMachinery()
.AddAssetManagement()
.ConfigureMachinery(async context =>
{
machine = await context
.AddMachine(new QualifiedName("Press"))
.WithIdentification(identification =>
{
identification.Manufacturer = new LocalizedText("Acme");
identification.SerialNumber = "SN-1";
identification.ProductInstanceUri = "urn:acme:press:1";
})
.BuildAsync(context.CancellationToken)
.ConfigureAwait(false);
})
.ConfigureDevicesFor(async context =>
{
asset = await context.GetRequiredService().RegisterAssetAsync(
machine!.AsNode(),
builder => builder.WithConfigurableAssetId("PRESS-1"),
context.CancellationToken).ConfigureAwait(false);
})).ConfigureAwait(false);
await using Connection connection = await Connection.OpenAsync(server, "machinery").ConfigureAwait(false);
AmbClient amb = connection.Client;
ArrayOf assets = await amb.DiscoverAssetsAsync().ConfigureAwait(false);
AssetIdentificationRecord identification = await amb.ReadIdentificationAsync(asset!.NodeId)
.ConfigureAwait(false);
await amb.WriteAssetIdAsync(asset.NodeId, "PRESS-2").ConfigureAwait(false);
ArrayOf moved = await amb.FindAssetsAsync("PRESS-2", AssetAliasCategory.ByAssetId)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(assets.ToArray(), Is.EqualTo(new[] { asset.NodeId }));
Assert.That(identification.ProductInstanceUri, Is.EqualTo("urn:acme:press:1"));
Assert.That(identification.SerialNumber, Is.EqualTo("SN-1"));
Assert.That(identification.Manufacturer.Text, Is.EqualTo("Acme"));
Assert.That(identification.AssetId, Is.EqualTo("PRESS-1"));
Assert.That(moved.Count, Is.EqualTo(1));
Assert.That(
server.Manager.ConformanceUnits.ToArray(),
Does.Contain(new QualifiedName("AMB Asset Identification")),
"the machine publishes its ProductInstanceUri in its Identification group");
});
}
///
/// Reads stream entries, poking the server until one matches: an event
/// stream says nothing until its monitored item is up, so the poke
/// repeats until the first matching event arrives.
///
/// The type of the stream entries.
private static async Task NextAsync(
IAsyncEnumerator stream,
Func poke,
Func match,
CancellationToken cancellationToken)
{
Task next = stream.MoveNextAsync().AsTask();
while (true)
{
Task done = await Task.WhenAny(next, Task.Delay(500, cancellationToken)).ConfigureAwait(false);
if (done == next)
{
Assert.That(await next.ConfigureAwait(false), Is.True, "the stream ended");
if (match(stream.Current))
{
return stream.Current;
}
next = stream.MoveNextAsync().AsTask();
continue;
}
cancellationToken.ThrowIfCancellationRequested();
await poke().ConfigureAwait(false);
}
}
private static List ListOf(ArrayOf items)
{
var list = new List(items.Count);
foreach (T item in items)
{
list.Add(item);
}
return list;
}
private static async Task ReadAsync(ManagedSession session, NodeId nodeId)
{
ReadResponse response = await session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
[new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }],
CancellationToken.None).ConfigureAwait(false);
return response.Results[0];
}
///
/// A hosted server with three Device Integration devices: a motor, a
/// pump that contains it and uses every AMB building block, and a
/// sensor that utilizes the pump.
///
private sealed class Plant : IAsyncDisposable
{
private Plant(AmbHostedServer server)
{
Server = server;
}
public AmbHostedServer Server { get; }
public IAssetHandle? Motor { get; private set; }
public IAssetHandle? Pump { get; private set; }
public IAssetHandle? Sensor { get; private set; }
public static async Task StartAsync(string name, string? state)
{
IAssetHandle? motor = null;
IAssetHandle? pump = null;
IAssetHandle? sensor = null;
AmbHostedServer server = await AmbHostedServer.StartAsync(
name,
builder => builder
.AddOpcUaDi()
.AddAssetManagement(options =>
{
// AddLink needs a persistent store (§10.5.3).
options.UseFileSystemStores(state ??
Path.Combine(
TestContext.CurrentContext.WorkDirectory,
nameof(AmbClientServerE2eTests),
Guid.NewGuid().ToString("N")));
options.AuthorizeLinkEdit = _ => true;
})
.ConfigureDevicesFor(async context =>
{
IAssetManagement assets = context.GetRequiredService();
IDeviceBuilder motorDevice = await AssetRegistrationTests
.CreateDeviceAsync(context, "Motor", MotorUri).ConfigureAwait(false);
motor = await motorDevice.RegisterAsAssetAsync(assets).ConfigureAwait(false);
IDeviceBuilder pumpDevice = await AssetRegistrationTests
.CreateDeviceAsync(context, "Pump", PumpUri).ConfigureAwait(false);
pump = await pumpDevice.RegisterAsAssetAsync(
assets,
asset => asset
.WithConfigurableAssetId("P-1")
.WithDeviceHealth(deriveFromAlarms: true)
.WithHealthAlarm(
"Fieldbus",
AssetHealthAlarmKind.Failure,
AmbConditionClass.ConnectionFailure)
.WithMaintenance(
"AnnualInspection",
AmbConditionClass.Inspection,
details =>
{
details.Description = new LocalizedText("Annual inspection.");
details.PlannedDate = s_planned;
})
.WithDocumentationLinks(links => links
.Add("Manual", "https://acme.example/pump/manual.pdf")
.AddEditable("Handbook")
.AllowUserLinks())
.WithVersionInformation("HW-1", "SW-1", 1)
.LocatedIn(AssetLocationKind.Hierarchical, "Plant1/Hall3/Line2")
.WithLocation(AssetLocationKind.Hierarchical, "Plant1/Hall3/Line2")
.WithLocalTime(60)
.ClassifiedAs(s_eclass)
.WithRequirements(requirements => requirements
.Add("SupplyVoltage", Variant.From(24.0)))
.RelatesTo(Ua.ReferenceTypeIds.HasComponent, motor.NodeId),
context.CancellationToken).ConfigureAwait(false);
IDeviceBuilder sensorDevice = await AssetRegistrationTests
.CreateDeviceAsync(context, "Sensor", SensorUri).ConfigureAwait(false);
sensor = await sensorDevice.RegisterAsAssetAsync(
assets,
asset => asset.RelatesTo(Ua.ReferenceTypeIds.Utilizes, pump.NodeId),
context.CancellationToken).ConfigureAwait(false);
})).ConfigureAwait(false);
return new Plant(server) { Motor = motor, Pump = pump, Sensor = sensor };
}
public ValueTask DisposeAsync()
{
return Server.DisposeAsync();
}
}
///
/// A managed session with the AMB client on it.
///
private sealed class Connection : IAsyncDisposable
{
private Connection(ClientFixture fixture, ManagedSession session, AmbClient client)
{
m_fixture = fixture;
Session = session;
Client = client;
}
public ManagedSession Session { get; }
public AmbClient Client { get; }
public static async Task OpenAsync(AmbHostedServer server, string name)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var fixture = new ClientFixture(telemetry);
await fixture.LoadClientConfigurationAsync(Path.Combine(
TestContext.CurrentContext.WorkDirectory,
nameof(AmbClientServerE2eTests),
Guid.NewGuid().ToString("N"),
"client-pki")).ConfigureAwait(false);
EndpointDescription? endpoint = await CoreClientUtils.SelectEndpointAsync(
fixture.Config,
server.EndpointUrl,
useSecurity: false,
discoverTimeout: 15000,
telemetry).ConfigureAwait(false);
Assert.That(endpoint, Is.Not.Null);
ManagedSession session = await new ManagedSessionBuilder(fixture.Config, telemetry)
.UseEndpoint(new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(fixture.Config)))
.WithSessionName(name)
.ConnectAsync().ConfigureAwait(false);
return new Connection(fixture, session, session.AssetManagement(telemetry));
}
public async ValueTask DisposeAsync()
{
try
{
await Session.DisposeAsync().ConfigureAwait(false);
}
finally
{
m_fixture.Dispose();
}
}
private readonly ClientFixture m_fixture;
}
}
}