@@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly StaleConfigFlag? _staleConfigFlag;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_staleConfigFlag = staleConfigFlag;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_equipmentContentLookup = equipmentContentLookup;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -103,11 +106,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// Drive each driver's discovery through its node manager. The node manager IS the
|
||||
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
||||
// its internal map and wires OnAlarmEvent → sink routing.
|
||||
//
|
||||
// ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an
|
||||
// Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync
|
||||
// so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders +
|
||||
// the five identifier properties (decision #121) are in place. DiscoverAsync then
|
||||
// streams the driver's native shape on top; Tag rows bound to Equipment already
|
||||
// materialized via the walker don't get duplicated because the driver's DiscoverAsync
|
||||
// output is authoritative for its own native references only.
|
||||
foreach (var nodeManager in _server.DriverNodeManagers)
|
||||
{
|
||||
var driverId = nodeManager.Driver.DriverInstanceId;
|
||||
try
|
||||
{
|
||||
if (_equipmentContentLookup is not null)
|
||||
{
|
||||
var content = _equipmentContentLookup(driverId);
|
||||
if (content is not null)
|
||||
{
|
||||
ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content);
|
||||
_logger.LogInformation(
|
||||
"UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}",
|
||||
content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId);
|
||||
}
|
||||
}
|
||||
|
||||
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
||||
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user