From 70a5d06b37c9abef5265bf4d8efe2684a527c431 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 06:28:01 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=209=20=E2=80=94=20thread=20IsAla?= =?UTF-8?q?rm=20discovery=20flag=20end-to-end.=20GalaxyRepository.GetAttri?= =?UTF-8?q?butesAsync=20has=20always=20emitted=20is=5Falarm=20alongside=20?= =?UTF-8?q?is=5Fhistorized=20(CASE=20WHEN=20EXISTS=20with=20the=20primitiv?= =?UTF-8?q?e=5Fdefinition=20join=20on=20primitive=5Fname=3D'AlarmExtension?= =?UTF-8?q?'=20per=20v1's=20Extended=20Attributes=20SQL=20lifted=20byte-fo?= =?UTF-8?q?r-byte=20into=20the=20PR=205=20repository=20port),=20and=20Gala?= =?UTF-8?q?xyAttributeRow.IsAlarm=20has=20been=20populated=20since=20the?= =?UTF-8?q?=20port,=20but=20the=20flag=20was=20silently=20dropped=20at=20t?= =?UTF-8?q?he=20MapAttribute=20helper=20in=20both=20MxAccessGalaxyBackend?= =?UTF-8?q?=20and=20DbBackedGalaxyBackend=20because=20GalaxyAttributeInfo?= =?UTF-8?q?=20on=20the=20IPC=20side=20had=20no=20field=20to=20carry=20it?= =?UTF-8?q?=20=E2=80=94=20every=20deployed=20alarm=20attribute=20arrived?= =?UTF-8?q?=20at=20the=20Proxy=20with=20no=20signal=20that=20it=20was=20al?= =?UTF-8?q?arm-bearing.=20This=20PR=20wires=20the=20flag=20through=20the?= =?UTF-8?q?=20three=20translation=20boundaries:=20GalaxyAttributeInfo=20ga?= =?UTF-8?q?ins=20[Key(6)]=20public=20bool=20IsAlarm=20{=20get;=20set;=20}?= =?UTF-8?q?=20at=20the=20end=20of=20the=20message=20to=20preserve=20wire-c?= =?UTF-8?q?ompat=20with=20pre-PR9=20payloads=20that=20omit=20the=20key=20(?= =?UTF-8?q?MessagePack=20treats=20missing=20keys=20as=20default,=20so=20a?= =?UTF-8?q?=20newer=20Proxy=20talking=20to=20an=20older=20Host=20simply=20?= =?UTF-8?q?gets=20IsAlarm=3Dfalse=20for=20every=20attribute);=20both=20bac?= =?UTF-8?q?kend=20MapAttribute=20helpers=20copy=20row.IsAlarm=20into=20the?= =?UTF-8?q?=20IPC=20shape;=20DriverAttributeInfo=20in=20Core.Abstractions?= =?UTF-8?q?=20gains=20a=20new=20IsAlarm=20parameter=20with=20default=20val?= =?UTF-8?q?ue=20false=20so=20the=20positional=20record=20signature=20chang?= =?UTF-8?q?e=20doesn't=20force=20every=20non-Galaxy=20driver=20call=20site?= =?UTF-8?q?=20to=20flow=20a=20flag=20they=20don't=20produce=20(the=20exist?= =?UTF-8?q?ing=20generic=20node-manager=20and=20future=20Modbus/etc.=20dri?= =?UTF-8?q?vers=20keep=20compiling=20without=20modification);=20GalaxyProx?= =?UTF-8?q?yDriver.DiscoverAsync=20passes=20attr.IsAlarm=20through=20to=20?= =?UTF-8?q?the=20DriverAttributeInfo=20positional=20constructor.=20This=20?= =?UTF-8?q?is=20the=20discovery-side=20foundation=20=E2=80=94=20the=20gene?= =?UTF-8?q?ric=20node-manager=20can=20now=20enrich=20alarm-bearing=20varia?= =?UTF-8?q?bles=20with=20OPC=20UA=20AlarmConditionState=20during=20address?= =?UTF-8?q?-space=20build=20(the=20existing=20v1=20LmxNodeManager=20patter?= =?UTF-8?q?n=20that=20subscribes=20to=20.InAlarm=20+=20.Priority=20+?= =?UTF-8?q?=20.DescAttrName=20+=20.Acked=20and=20merges=20them=20into=20a?= =?UTF-8?q?=20ConditionState)=20but=20this=20PR=20deliberately=20stops=20a?= =?UTF-8?q?t=20discovery:=20the=20full=20alarm=20subsystem=20(subscription?= =?UTF-8?q?=20management=20for=20the=204=20alarm-status=20attributes,=20st?= =?UTF-8?q?ate-machine=20tracking=20for=20Active/Unacknowledged/Confirmed/?= =?UTF-8?q?Inactive=20transitions,=20OPC=20UA=20Part=209=20alarm=20event?= =?UTF-8?q?=20emission,=20and=20the=20write-to-AckMsg=20ack=20path)=20is?= =?UTF-8?q?=20a=20follow-up=20PR=2010+=20because=20it=20touches=20the=20no?= =?UTF-8?q?de-manager's=20address-space=20build=20path=20=E2=80=94=20ortho?= =?UTF-8?q?gonal=20to=20the=20IPC=20flow=20this=20PR=20covers.=20Tests=20?= =?UTF-8?q?=E2=80=94=20AlarmDiscoveryTests=20(new,=203=20cases):=20GalaxyA?= =?UTF-8?q?ttributeInfo=5FIsAlarm=5Fround=5Ftrips=5Ftrue=5Fthrough=5FMessa?= =?UTF-8?q?gePack=20serializes=20an=20IsAlarm=3Dtrue=20instance=20and=20as?= =?UTF-8?q?serts=20the=20decoded=20flag=20is=20true=20+=20IsHistorized=20i?= =?UTF-8?q?s=20true=20+=20AttributeName=20survives=20unchanged;=20GalaxyAt?= =?UTF-8?q?tributeInfo=5FIsAlarm=5Fround=5Ftrips=5Ffalse=5Fthrough=5FMessa?= =?UTF-8?q?gePack=20covers=20the=20default=20path;=20Pre=5FPR9=5Fpayload?= =?UTF-8?q?=5Fwithout=5FIsAlarm=5Fkey=5Fdeserializes=5Fwith=5Fdefault=5Ffa?= =?UTF-8?q?lse=20is=20the=20wire-compat=20regression=20guard=20=E2=80=94?= =?UTF-8?q?=20serializes=20a=20stand-in=20PrePR9Shape=20class=20with=20onl?= =?UTF-8?q?y=20keys=200..5=20(identical=20layout=20to=20the=20pre-PR9=20Ga?= =?UTF-8?q?laxyAttributeInfo)=20and=20asserts=20the=20newer=20GalaxyAttrib?= =?UTF-8?q?uteInfo=20deserializer=20produces=20IsAlarm=3Dfalse=20without?= =?UTF-8?q?=20throwing,=20so=20a=20rolling=20upgrade=20where=20the=20Proxy?= =?UTF-8?q?=20ships=20first=20can=20talk=20to=20an=20old=20Host=20during?= =?UTF-8?q?=20the=20window=20before=20the=20Host=20upgrades=20without=20a?= =?UTF-8?q?=20MessagePack=20"missing=20key"=20exception.=20Full=20solution?= =?UTF-8?q?=20build:=200=20errors,=2038=20warnings=20(existing).=20Galaxy.?= =?UTF-8?q?Host.Tests=20Unit=20suite:=2027=20pass=20/=200=20fail=20(3=20ne?= =?UTF-8?q?w=20alarm-discovery=20+=209=20PR5=20historian=20+=2015=20pre-ex?= =?UTF-8?q?isting).=20This=20PR=20branches=20off=20phase-2-pr5-historian?= =?UTF-8?q?=20because=20GalaxyProxyDriver's=20constructor=20signature=20+?= =?UTF-8?q?=20GalaxyHierarchyRow's=20IsAlarm=20init-only=20property=20are?= =?UTF-8?q?=20both=20ancestor=20state=20that=20the=20simpler=20branch=20ba?= =?UTF-8?q?ses=20(phase-2-pr4-findings,=20master)=20don't=20yet=20include.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DriverAttributeInfo.cs | 9 +- .../Backend/DbBackedGalaxyBackend.cs | 1 + .../Backend/MxAccessGalaxyBackend.cs | 1 + .../GalaxyProxyDriver.cs | 3 +- .../Contracts/Discovery.cs | 9 ++ .../AlarmDiscoveryTests.cs | 84 +++++++++++++++++++ 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs 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 95a626b..d271cb0 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 @@ -138,6 +138,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 7cd543a..ff20b63 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 @@ -313,6 +313,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 ee4a2d1..8cbd08b 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; } + } +}