From f1f53e17896bc6a84cb6ee1875c32181c4c53329 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 19:41:01 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207=20Stream=20G=20=E2=80=94=20Address-sp?= =?UTF-8?q?ace=20integration=20(NodeSourceKind=20+=20walker=20emits=20Virt?= =?UTF-8?q?ualTag/ScriptedAlarm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or ScriptedAlarmEngine (Phase 7 Stream C). ## Changes - NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2). - DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all default so existing call sites (every driver) compile unchanged. - EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections. - EquipmentNodeWalker emits: - Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored - Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true (triggers node-manager AlarmConditionState materialization) - Skips disabled virtual tags + scripted alarms ## Tests — 13/13 in EquipmentNodeWalkerTests (5 new) - Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag - Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type - Disabled rows skipped - Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers) - Driver tags default Source=Driver (ensures no discriminator regression) ## Next Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine, end-to-end integration test. --- .../DriverAttributeInfo.cs | 31 ++++- .../OpcUa/EquipmentNodeWalker.cs | 77 +++++++++++- .../OpcUa/EquipmentNodeWalkerTests.cs | 111 ++++++++++++++++++ 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs index 1c24020..dc9a7fb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs @@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// (holding registers with level-set values, set-point writes to analog tags) — the /// capability invoker respects this flag when deciding whether to apply Polly retry. /// +/// +/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch. +/// Defaults to so existing callers are unchanged. +/// +/// +/// Set when is — stable +/// logical id the VirtualTagEngine addresses by. Null otherwise. +/// +/// +/// Set when is — +/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise. +/// public sealed record DriverAttributeInfo( string FullName, DriverDataType DriverDataType, @@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo( SecurityClassification SecurityClass, bool IsHistorized, bool IsAlarm = false, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + NodeSourceKind Source = NodeSourceKind.Driver, + string? VirtualTagId = null, + string? ScriptedAlarmId = null); + +/// +/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/ +/// Subscribe dispatch. Driver = a real IDriver capability surface; +/// Virtual = a Phase 7 .VirtualTagId'd tag +/// computed by the VirtualTagEngine; ScriptedAlarm = a scripted Part 9 alarm +/// materialized by the ScriptedAlarmEngine. +/// +public enum NodeSourceKind +{ + Driver = 0, + Virtual = 1, + ScriptedAlarm = 2, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs index 85a79ec..143ae61 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -87,6 +87,16 @@ public static class EquipmentNodeWalker .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + var virtualTagsByEquipment = (content.VirtualTags ?? []) + .Where(v => v.Enabled) + .GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + + var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? []) + .Where(a => a.Enabled) + .GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); + foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal)) { var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name); @@ -103,9 +113,17 @@ public static class EquipmentNodeWalker AddIdentifierProperties(equipmentBuilder, equipment); IdentificationFolderBuilder.Build(equipmentBuilder, equipment); - if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue; - foreach (var tag in equipmentTags) - AddTagVariable(equipmentBuilder, tag); + if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) + foreach (var tag in equipmentTags) + AddTagVariable(equipmentBuilder, tag); + + if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags)) + foreach (var vtag in vTags) + AddVirtualTagVariable(equipmentBuilder, vtag); + + if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms)) + foreach (var alarm in alarms) + AddScriptedAlarmVariable(equipmentBuilder, alarm); } } } @@ -157,6 +175,55 @@ public static class EquipmentNodeWalker /// private static DriverDataType ParseDriverDataType(string raw) => Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String; + + /// + /// Emit a row as a + /// variable node. FullName doubles as the UNS path Phase 7's VirtualTagEngine + /// addresses its engine-side entries by. The VirtualTagId discriminator lets + /// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any + /// driver. + /// + private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag) + { + var attr = new DriverAttributeInfo( + FullName: vtag.VirtualTagId, + DriverDataType: ParseDriverDataType(vtag.DataType), + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.FreeAccess, + IsHistorized: vtag.Historize, + IsAlarm: false, + WriteIdempotent: false, + Source: NodeSourceKind.Virtual, + VirtualTagId: vtag.VirtualTagId, + ScriptedAlarmId: null); + equipmentBuilder.Variable(vtag.Name, vtag.Name, attr); + } + + /// + /// Emit a row as a + /// variable node. The OPC UA Part 9 alarm-condition materialization happens at the + /// node-manager level (which wires the concrete AlarmConditionState subclass + /// per ); this walker provides the browse-level + /// anchor + the flag that triggers that + /// materialization path. + /// + private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm) + { + var attr = new DriverAttributeInfo( + FullName: alarm.ScriptedAlarmId, + DriverDataType: DriverDataType.Boolean, + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.FreeAccess, + IsHistorized: false, + IsAlarm: true, + WriteIdempotent: false, + Source: NodeSourceKind.ScriptedAlarm, + VirtualTagId: null, + ScriptedAlarmId: alarm.ScriptedAlarmId); + equipmentBuilder.Variable(alarm.Name, alarm.Name, attr); + } } /// @@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent( IReadOnlyList Areas, IReadOnlyList Lines, IReadOnlyList Equipment, - IReadOnlyList Tags); + IReadOnlyList Tags, + IReadOnlyList? VirtualTags = null, + IReadOnlyList? ScriptedAlarms = null); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs index 71d0203..c656c85 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs @@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String); } + [Fact] + public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var vtag = new VirtualTag + { + VirtualTagRowId = Guid.NewGuid(), GenerationId = 1, + VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate", + DataType = "Float32", ScriptId = "scr-1", Historize = true, + }; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [], VirtualTags: [vtag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var equipmentNode = rec.Children[0].Children[0].Children[0]; + var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate"); + v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual); + v.AttributeInfo.VirtualTagId.ShouldBe("vt-1"); + v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); + v.AttributeInfo.IsHistorized.ShouldBeTrue(); + v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); + } + + [Fact] + public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var alarm = new ScriptedAlarm + { + ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1, + ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp", + AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded", + PredicateScriptId = "scr-9", Severity = 800, + }; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [], ScriptedAlarms: [alarm]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp"); + v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm); + v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1"); + v.AttributeInfo.VirtualTagId.ShouldBeNull(); + v.AttributeInfo.IsAlarm.ShouldBeTrue(); + v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean); + } + + [Fact] + public void Walk_Skips_Disabled_VirtualTags_And_Alarms() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var vtag = new VirtualTag + { + VirtualTagRowId = Guid.NewGuid(), GenerationId = 1, + VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled", + DataType = "Float32", ScriptId = "scr-1", Enabled = false, + }; + var alarm = new ScriptedAlarm + { + ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1, + ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm", + AlarmType = "LimitAlarm", MessageTemplate = "x", + PredicateScriptId = "scr-9", Enabled = false, + }; + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty(); + } + + [Fact] + public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe() + { + // Backwards-compat — callers that don't populate the new collections still work. + var eq = Eq("eq-1", "line-1", "oven-3"); + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); // must not throw + + rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty(); + } + + [Fact] + public void Driver_tag_default_NodeSourceKind_is_Driver() + { + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1"); + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [tag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var v = rec.Children[0].Children[0].Children[0].Variables.Single(); + v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver); + v.AttributeInfo.VirtualTagId.ShouldBeNull(); + v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); + } + // ----- builders for test seed rows ----- private static UnsArea Area(string id, string name) => new()