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()