diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs index bbe649d..7071770 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs @@ -19,10 +19,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// Declared array length when is true; null otherwise. /// Write-authorization tier for this attribute. /// True when this attribute is expected to feed historian / HistoryRead. +/// +/// True when this attribute represents an alarm condition (Galaxy: has an +/// AlarmExtension primitive). The generic node-manager enriches the variable with an +/// OPC UA AlarmConditionState when true. Defaults to false so existing non-Galaxy +/// drivers aren't forced to flow a flag they don't produce. +/// public sealed record DriverAttributeInfo( string FullName, DriverDataType DriverDataType, bool IsArray, uint? ArrayDim, SecurityClassification SecurityClass, - bool IsHistorized); + bool IsHistorized, + bool IsAlarm = false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs index 9c505db..29bd850 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs @@ -147,6 +147,7 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null, SecurityClassification = row.SecurityClassification, IsHistorized = row.IsHistorized, + IsAlarm = row.IsAlarm, }; /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 663676d..290fa29 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -392,6 +392,7 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null, SecurityClassification = row.SecurityClassification, IsHistorized = row.IsHistorized, + IsAlarm = row.IsAlarm, }; private static string MapCategory(int categoryId) => categoryId switch diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs index 41086cb..c8749ca 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs @@ -123,7 +123,8 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options) IsArray: attr.IsArray, ArrayDim: attr.ArrayDim, SecurityClass: MapSecurity(attr.SecurityClassification), - IsHistorized: attr.IsHistorized)); + IsHistorized: attr.IsHistorized, + IsAlarm: attr.IsAlarm)); } } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs index 7ba7170..1092707 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs @@ -30,6 +30,15 @@ public sealed class GalaxyAttributeInfo [Key(3)] public uint? ArrayDim { get; set; } [Key(4)] public int SecurityClassification { get; set; } [Key(5)] public bool IsHistorized { get; set; } + + /// + /// True when the attribute has an AlarmExtension primitive in the Galaxy repository + /// (primitive_definition.primitive_name = 'AlarmExtension'). The generic + /// node-manager uses this to enrich the variable's OPC UA node with an + /// AlarmConditionState during address-space build. Added in PR 9 as the + /// discovery-side foundation for the alarm event wire-up that follows in PR 10+. + /// + [Key(6)] public bool IsAlarm { get; set; } } [MessagePackObject] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs new file mode 100644 index 0000000..27541ab --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs @@ -0,0 +1,84 @@ +using System; +using MessagePack; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class AlarmDiscoveryTests +{ + /// + /// PR 9 — IsAlarm must survive the MessagePack round-trip at Key=6 position. + /// Regression guard: any reorder of keys in GalaxyAttributeInfo would silently corrupt + /// the flag in the wire payload since MessagePack encodes by key number, not field name. + /// + [Fact] + public void GalaxyAttributeInfo_IsAlarm_round_trips_true_through_MessagePack() + { + var input = new GalaxyAttributeInfo + { + AttributeName = "TankLevel", + MxDataType = 2, + IsArray = false, + ArrayDim = null, + SecurityClassification = 1, + IsHistorized = true, + IsAlarm = true, + }; + + var bytes = MessagePackSerializer.Serialize(input); + var decoded = MessagePackSerializer.Deserialize(bytes); + + decoded.IsAlarm.ShouldBeTrue(); + decoded.IsHistorized.ShouldBeTrue(); + decoded.AttributeName.ShouldBe("TankLevel"); + } + + [Fact] + public void GalaxyAttributeInfo_IsAlarm_round_trips_false_through_MessagePack() + { + var input = new GalaxyAttributeInfo { AttributeName = "ColorRgb", IsAlarm = false }; + var bytes = MessagePackSerializer.Serialize(input); + var decoded = MessagePackSerializer.Deserialize(bytes); + decoded.IsAlarm.ShouldBeFalse(); + } + + /// + /// Wire-compat guard: payloads serialized before PR 9 (which omit Key=6) must still + /// deserialize cleanly — MessagePack treats missing keys as default. This lets a newer + /// Proxy talk to an older Host during a rolling upgrade without a crash. + /// + [Fact] + public void Pre_PR9_payload_without_IsAlarm_key_deserializes_with_default_false() + { + // Build a 6-field payload (keys 0..5) matching the pre-PR9 shape by serializing a + // stand-in class with the same key layout but no Key=6. + var pre = new PrePR9Shape + { + AttributeName = "Legacy", + MxDataType = 1, + IsArray = false, + ArrayDim = null, + SecurityClassification = 0, + IsHistorized = false, + }; + var bytes = MessagePackSerializer.Serialize(pre); + + var decoded = MessagePackSerializer.Deserialize(bytes); + decoded.AttributeName.ShouldBe("Legacy"); + decoded.IsAlarm.ShouldBeFalse(); + } + + [MessagePackObject] + public sealed class PrePR9Shape + { + [Key(0)] public string AttributeName { get; set; } = string.Empty; + [Key(1)] public int MxDataType { get; set; } + [Key(2)] public bool IsArray { get; set; } + [Key(3)] public uint? ArrayDim { get; set; } + [Key(4)] public int SecurityClassification { get; set; } + [Key(5)] public bool IsHistorized { get; set; } + } +}