ADR-001 wire-in � EquipmentNodeWalker in OpcUaApplicationHost (#212 + #213) #155

Merged
dohertj2 merged 1 commits from equipment-walker-wire-in into v2 2026-04-20 03:11:36 -04:00
2 changed files with 229 additions and 1 deletions

View File

@@ -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);

View File

@@ -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);
}
}
}