diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index cba0b49..a1491e1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private readonly StaleConfigFlag? _staleConfigFlag; private readonly Func? _tierLookup; private readonly Func? _resilienceConfigLookup; + private readonly Func? _equipmentContentLookup; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private ApplicationInstance? _application; @@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable NodeScopeResolver? scopeResolver = null, StaleConfigFlag? staleConfigFlag = null, Func? tierLookup = null, - Func? resilienceConfigLookup = null) + Func? resilienceConfigLookup = null, + Func? 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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs new file mode 100644 index 0000000..abfb4f4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs @@ -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; + +/// +/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when +/// is given an equipmentContentLookup that +/// returns a non-null , 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. +/// +[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.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 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); + } + + /// + /// 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). + /// + 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> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + IReadOnlyList result = + fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray(); + return Task.FromResult(result); + } + } +}