206 lines
9.5 KiB
C#
206 lines
9.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
|
|
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
|
|
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
|
|
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
|
|
/// identifier properties are materialized into the driver's namespace + visible to an
|
|
/// OPC UA client via standard browse.
|
|
/// </summary>
|
|
[Trait("Category", "Integration")]
|
|
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
|
|
{
|
|
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
|
|
private const string DriverId = "galaxy-prod";
|
|
|
|
private DriverHost _driverHost = null!;
|
|
private OpcUaApplicationHost _server = null!;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
_driverHost = new DriverHost();
|
|
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
|
|
|
|
var content = BuildFixture();
|
|
|
|
var options = new OpcUaServerOptions
|
|
{
|
|
EndpointUrl = _endpoint,
|
|
ApplicationName = "OtOpcUaWalkerTest",
|
|
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
|
|
PkiStoreRoot = _pkiRoot,
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
HealthEndpointsEnabled = false,
|
|
};
|
|
|
|
_server = new OpcUaApplicationHost(
|
|
options, _driverHost, new DenyAllUserAuthenticator(),
|
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
|
|
equipmentContentLookup: id => id == DriverId ? content : null);
|
|
|
|
await _server.StartAsync(CancellationToken.None);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _server.DisposeAsync();
|
|
await _driverHost.DisposeAsync();
|
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
|
|
|
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
|
|
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
|
|
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
|
|
|
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
|
|
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
|
|
|
|
var equipmentChildren = BrowseChildren(session, equipmentFolder);
|
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
|
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
|
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
|
|
|
var tagNode = new NodeId("plcaddr-temperature", nsIndex);
|
|
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
|
|
|
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
|
|
|
|
var dv = session.ReadValue(tagNode);
|
|
dv.ShouldNotBeNull();
|
|
}
|
|
|
|
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
|
|
{
|
|
session.Browse(null, null, node, 0, BrowseDirection.Forward,
|
|
ReferenceTypeIds.HierarchicalReferences, true,
|
|
(uint)NodeClass.Object | (uint)NodeClass.Variable,
|
|
out _, out var refs);
|
|
return refs;
|
|
}
|
|
|
|
private static EquipmentNamespaceContent BuildFixture()
|
|
{
|
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
|
|
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
|
var oven = new Equipment
|
|
{
|
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
|
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
|
MachineCode = "MC-oven-3",
|
|
};
|
|
var tempTag = new Tag
|
|
{
|
|
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
|
|
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
|
Name = "Temperature", DataType = "Int32",
|
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
|
};
|
|
|
|
return new EquipmentNamespaceContent(
|
|
Areas: new[] { area },
|
|
Lines: new[] { line },
|
|
Equipment: new[] { oven },
|
|
Tags: new[] { tempTag });
|
|
}
|
|
|
|
private async Task<ISession> OpenSessionAsync()
|
|
{
|
|
var cfg = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUaWalkerTestClient",
|
|
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
|
SubjectName = "CN=OtOpcUaWalkerTestClient",
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
|
AutoAcceptUntrustedCertificates = true,
|
|
AddAppCertToTrustedStore = true,
|
|
},
|
|
TransportConfigurations = new TransportConfigurationCollection(),
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
|
};
|
|
await cfg.Validate(ApplicationType.Client);
|
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
|
|
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
|
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
|
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
|
|
/// walker is the sole source of address-space content; if the UNS folders appear
|
|
/// under browse, they came from the wire-in (not from the driver's own discovery).
|
|
/// </summary>
|
|
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
|
|
{
|
|
public EmptyDriver(string id) { DriverInstanceId = id; }
|
|
public string DriverInstanceId { get; }
|
|
public string DriverType => "EmptyForWalkerTest";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
IReadOnlyList<DataValueSnapshot> result =
|
|
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
}
|