ADR-001 wire-in — EquipmentNodeWalker runs inside OpcUaApplicationHost before driver DiscoverAsync, closing tasks #212 + #213. Completes the in-server half of the ADR-001 Option A story: Task A (PR #153) shipped the pure-function walker in Core.OpcUa; Task B (PR #154) shipped the NodeScopeResolver + ScopePathIndexBuilder + evaluator-level authz proof. This PR lands the BuildAddressSpaceAsync wire-in the walker was always meant to plug into + a full-stack OPC UA client-browse integration test that proves the UNS folder skeleton is actually visible to real UA clients end-to-end, not just to the RecordingBuilder test double. OpcUaApplicationHost gains an optional ctor parameter equipmentContentLookup of type Func<string, EquipmentNamespaceContent?>? — when supplied + non-null for a driver instance, EquipmentNodeWalker.Walk is invoked against that driver's node manager BEFORE GenericDriverNodeManager.BuildAddressSpaceAsync streams the driver's native DiscoverAsync output on top. Walker-first ordering matters: the UNS Area/Line/Equipment folder skeleton + Identification sub-folders + the five identifier properties (decision #121) are in place so driver-native references (driver-specific tag paths) land ALONGSIDE the UNS tree rather than racing it. Callers that don't supply a lookup (every existing pre-ADR-001 test + the v1 upgrade path) get identical behavior — the null-check is the backward-compat seam per the opt-in design sketched in ADR-001. The lookup delegate is driver-instance-scoped, not server-scoped, so a single server with multiple drivers can serve e.g. one Equipment-kind namespace (Galaxy proxy with a full UNS) alongside several native-kind namespaces (Modbus / AB CIP / TwinCAT / FOCAS that do not have their own UNS because decisions #116-#121 scope UNS to Equipment-kind only). SealedBootstrap.Start will wire this lookup against the Config-DB snapshot loader in a follow-up — the lookup plumbing lands first so that wiring reduces to one-line composition rather than a ctor-signature churn. New OpcUaEquipmentWalkerIntegrationTests spins up a real OtOpcUaServer on a non-default port with an EmptyDriver that registers with zero native content + a lookup that returns a seeded EquipmentNamespaceContent (one area warsaw / one line line-a / one equipment oven-3 / one tag Temperature). An OPC UA client session connects anonymously against the un-secured endpoint, browses the standard hierarchy, + asserts: (a) area folder warsaw contains line-a folder as a child; (b) line folder line-a contains oven-3 folder as a child; (c) equipment folder oven-3 contains EquipmentId + EquipmentUuid + MachineCode identifier properties — ZTag + SAPID correctly absent because the fixture leaves them null per decision #121 skip-when-null behavior; (d) the bound Tag emits a Variable node under the equipment folder with NodeId == Tag.TagConfig (the wire-level driver address) + the client can ReadValue against it end-to-end through the DriverNodeManager dispatch path. Because the EmptyDriver's DiscoverAsync is a no-op the test proves UNS content came from the walker, not the driver — the original ADR-001 question "what actually owns the browse tree" now has a mechanical answer visible at the OPC UA wire level. Test class uses its own port (48500+rand) + per-test PKI root so it runs in parallel with the existing OpcUaServerIntegrationTests fixture (48400+rand) without binding or cert collisions. Server project builds 0 errors; Server.Tests 181/181 (was 179, +2 new full-stack walker tests). Task #212 + #213 closed; the follow-up SealedBootstrap wiring is the natural next pickup because the ctor plumbing lands here + that becomes a narrow downstream PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 03:09:37 -04:00
parent 5811ede744
commit 2d97f241c0
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);