diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index d33b3ebd..ea3d6950 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -296,8 +296,12 @@ public class RealOpcUaClient : IOpcUaClient /// optional server-side WhereClause (M2.4 / #8). Only standard types appear /// here; vendor/custom type names cannot be mapped without browsing the server /// type tree, so they are handled by the client-side gate alone. + /// + /// Single source of truth for both directions: + /// is derived from this map, so the friendly-name and NodeId sides cannot drift. + /// /// - private static readonly IReadOnlyDictionary KnownConditionTypeIds = + internal static readonly IReadOnlyDictionary KnownConditionTypeIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ConditionType"] = ObjectTypeIds.ConditionType, @@ -322,6 +326,40 @@ public class RealOpcUaClient : IOpcUaClient ["CertificateExpirationAlarmType"] = ObjectTypeIds.CertificateExpirationAlarmType, }; + /// + /// Inverse of (NodeId → friendly name), derived + /// from it so the two cannot drift (M2.4 / #8). Used by + /// to translate the event-type NodeId an OPC UA server sends back into the friendly + /// type name the conditionFilter gate and server-side WhereClause both key off. + /// + private static readonly IReadOnlyDictionary ConditionTypeNamesById = + KnownConditionTypeIds.ToDictionary(kv => kv.Value, kv => kv.Key); + + /// + /// Resolves an event-type to the friendly condition-type name the + /// conditionFilter gate (and the server-side WhereClause) use (M2.4 / #8). + /// + /// + /// Standard A&C types are returned as their friendly name (e.g. i=9341 → + /// "ExclusiveLevelAlarmType") so the client-side gate — which compares against + /// the friendly names in — actually matches the + /// events the server delivers. Vendor/custom subtypes that are not in the map fall back + /// to the NodeId string; that is consistent because the WhereClause is likewise omitted + /// for unmapped names, so such a filter can only be expressed (and matched) as the NodeId + /// string. A null event type yields the empty string. + /// + /// + /// The event-type NodeId from the A&C notification, or null. + /// The friendly type name when known; otherwise the NodeId string (or "" when null). + internal static string ResolveAlarmTypeName(NodeId? eventType) + { + if (eventType is null) + return ""; + return ConditionTypeNamesById.TryGetValue(eventType, out var friendly) + ? friendly + : eventType.ToString(); + } + /// /// Builds the event filter selecting the base event fields plus the /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror, @@ -446,7 +484,12 @@ public class RealOpcUaClient : IOpcUaClient return; } - var sourceName = fields[1].Value is NodeId ? (fields[2].Value as string ?? "") : (fields[2].Value as string ?? ""); + // Field layout (AlarmStateFields): [1]=SourceNode (NodeId), [2]=SourceName (string). + // Prefer the human-readable SourceName; fall back to the SourceNode NodeId string + // only when SourceName is absent/empty, so the condition still has a stable key. + var sourceName = fields[2].Value as string; + if (string.IsNullOrEmpty(sourceName)) + sourceName = (fields[1].Value as NodeId)?.ToString() ?? ""; var conditionName = fields.Count > 11 ? fields[11].Value as string : null; var sourceObjectRef = sourceName; var sourceRef = string.IsNullOrEmpty(conditionName) ? sourceName : $"{sourceName}.{conditionName}"; @@ -476,7 +519,9 @@ public class RealOpcUaClient : IOpcUaClient onTransition(new NativeAlarmTransition( SourceReference: sourceRef, SourceObjectReference: sourceObjectRef, - AlarmTypeName: eventType?.ToString() ?? "", + // Resolve the event-type NodeId (e.g. "i=9341") to the friendly type name + // the conditionFilter gate keys off (M2.4 / #8); NodeId-string for custom types. + AlarmTypeName: ResolveAlarmTypeName(eventType), Kind: kind, Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity), Category: "", diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs index b7589e35..4cd9122f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs @@ -96,4 +96,18 @@ public class AlarmConditionFilterTests Assert.Equal(new[] { "AnalogLimit.Hi", "DiscreteAlarm" }, f.Names.OrderBy(n => n).ToArray()); Assert.Empty(AlarmConditionFilter.Parse(null).Names); } + + [Fact] + public void IsAllowed_OpcUaResolvedFriendlyName_MatchesFriendlyNameFilter() + { + // M2.4 (#8) regression: OPC UA delivers events whose AlarmTypeName, after + // RealOpcUaClient.ResolveAlarmTypeName, is a standard friendly type name + // (e.g. "ExclusiveLevelAlarmType"). A friendly-name filter on that source + // built a correct server WhereClause; the client gate must agree and deliver, + // not drop every event (which the prior NodeId-string AlarmTypeName caused). + var f = AlarmConditionFilter.Parse("ExclusiveLevelAlarmType,DiscreteAlarmType"); + Assert.True(f.IsAllowed(Tx("ExclusiveLevelAlarmType"))); + Assert.True(f.IsAllowed(Tx("DiscreteAlarmType"))); + Assert.False(f.IsAllowed(Tx("OffNormalAlarmType"))); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs new file mode 100644 index 00000000..852a5608 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/RealOpcUaClientAlarmFilterTests.cs @@ -0,0 +1,63 @@ +using Opc.Ua; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// M2.4 (#8) regression: standard OPC UA A&C events carry an event-type +/// (e.g. i=9341 for ExclusiveLevelAlarmType), but the +/// client-side conditionFilter gate — and the server-side WhereClause — both key off +/// the friendly type names in . +/// bridges the two by resolving the +/// event-type NodeId back to its friendly name (NodeId-string fallback for custom +/// types), so a friendly-name filter actually matches the events the server delivers. +/// +public class RealOpcUaClientAlarmFilterTests +{ + [Fact] + public void ResolveAlarmTypeName_KnownStandardNodeId_ReturnsFriendlyName() + { + // The well-known NodeId for ExclusiveLevelAlarmType (i=9341) must resolve to + // the friendly name the conditionFilter/WhereClause use. + var resolved = RealOpcUaClient.ResolveAlarmTypeName(ObjectTypeIds.ExclusiveLevelAlarmType); + Assert.Equal("ExclusiveLevelAlarmType", resolved); + } + + [Fact] + public void ResolveAlarmTypeName_DiscreteAlarmNodeId_ReturnsFriendlyName() + { + var resolved = RealOpcUaClient.ResolveAlarmTypeName(ObjectTypeIds.DiscreteAlarmType); + Assert.Equal("DiscreteAlarmType", resolved); + } + + [Fact] + public void ResolveAlarmTypeName_UnknownCustomNodeId_ReturnsNodeIdString() + { + // A vendor/custom subtype not in KnownConditionTypeIds: we cannot map it to a + // friendly name, so we fall back to its NodeId string. This is consistent — + // the WhereClause is also omitted for unknown names, so the client gate matches + // the NodeId string, which is the only thing such a filter could carry. + var custom = new NodeId(987654u, 7); + var resolved = RealOpcUaClient.ResolveAlarmTypeName(custom); + Assert.Equal(custom.ToString(), resolved); + } + + [Fact] + public void ResolveAlarmTypeName_Null_ReturnsEmptyString() + { + Assert.Equal("", RealOpcUaClient.ResolveAlarmTypeName(null)); + } + + [Fact] + public void InverseMap_RoundTrips_EveryKnownConditionType() + { + // The friendly→NodeId map (KnownConditionTypeIds) and the NodeId→friendly map + // are derived from a single source of truth, so they must round-trip for every + // entry — guards against the two maps drifting apart. + foreach (var (friendlyName, nodeId) in RealOpcUaClient.KnownConditionTypeIds) + { + var resolved = RealOpcUaClient.ResolveAlarmTypeName(nodeId); + Assert.Equal(friendlyName, resolved); + } + } +}