diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
index 82e1965f..fa6e8e34 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs
@@ -99,8 +99,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// routed to subscribers (NativeAlarmActors) by source-object reference.
/// sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count.
private readonly Dictionary> _alarmSourceSubscribers = new();
- /// sourceReference → optional condition filter (first subscriber wins).
+ /// sourceReference → raw condition filter string passed to the adapter (first subscriber wins).
private readonly Dictionary _alarmSourceFilter = new();
+ ///
+ /// sourceReference → parsed condition-type predicate (M2.4 / #8). The authoritative
+ /// client-side gate in ; applies uniformly
+ /// across OPC UA and the gateway-wide MxGateway feed.
+ ///
+ private readonly Dictionary _alarmSourceFilterPredicate = new();
/// sourceReference → adapter alarm subscription id.
private readonly Dictionary _alarmSubscriptionIds = new();
/// sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight.
@@ -1480,6 +1486,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
}
subs.Add(subscriber);
_alarmSourceFilter[request.SourceReference] = request.ConditionFilter;
+ // Parse the type-name filter once; this is the authoritative client-side
+ // gate consulted on every routed transition (M2.4 / #8).
+ _alarmSourceFilterPredicate[request.SourceReference] = AlarmConditionFilter.Parse(request.ConditionFilter);
// If the adapter feed for this source is already (being) established, the
// existing subscription serves the new subscriber too.
@@ -1546,6 +1555,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
if (!match)
continue;
+ // M2.4 (#8): authoritative client-side condition-type gate. Applied
+ // per matched source because two sources may share a prefix yet carry
+ // different filters. Empty filter = allow all (historical behaviour);
+ // framing sentinels (SnapshotComplete) are never dropped.
+ if (_alarmSourceFilterPredicate.TryGetValue(sourceRef, out var predicate) &&
+ !predicate.IsAllowed(transition))
+ continue;
+
foreach (var sub in subs)
{
if (notified.Add(sub))
@@ -1566,6 +1583,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// No subscribers remain for this source — tear down the adapter feed.
_alarmSourceSubscribers.Remove(request.SourceReference);
_alarmSourceFilter.Remove(request.SourceReference);
+ _alarmSourceFilterPredicate.Remove(request.SourceReference);
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
_adapter is IAlarmSubscribableConnection alarmable)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
index a1e83886..2e6ec9d4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs
@@ -163,7 +163,11 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
_alarmCts = new CancellationTokenSource();
var token = _alarmCts.Token;
var client = _client!;
- // Gateway-wide feed (null prefix); the actor filters per source reference.
+ // Gateway-wide feed (null prefix). The MxGateway has no server-side
+ // condition filter, so conditionFilter is intentionally NOT forwarded
+ // here: the DataConnectionActor applies it as the authoritative
+ // client-side gate per source reference AND per condition type
+ // (M2.4 / #8 — AlarmConditionFilter), uniform with the OPC UA path.
_ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token);
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index d24dfa85..d33b3ebd 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -258,7 +258,9 @@ public class RealOpcUaClient : IOpcUaClient
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = 1000,
- Filter = BuildAlarmEventFilter()
+ // Server-side WhereClause is a bandwidth optimisation only — the
+ // authoritative condition-type gate lives in DataConnectionActor (M2.4 / #8).
+ Filter = BuildAlarmEventFilter(AlarmConditionFilter.Parse(conditionFilter))
};
item.Notification += (_, e) =>
@@ -289,10 +291,56 @@ public class RealOpcUaClient : IOpcUaClient
}
///
- /// Builds the event filter selecting the base event fields plus the
- /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror.
+ /// Maps the standard OPC UA Alarms & Conditions type names (case-insensitive)
+ /// to their well-known NodeIds, for building the
+ /// 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.
///
- private static EventFilter BuildAlarmEventFilter()
+ private static readonly IReadOnlyDictionary KnownConditionTypeIds =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ConditionType"] = ObjectTypeIds.ConditionType,
+ ["AcknowledgeableConditionType"] = ObjectTypeIds.AcknowledgeableConditionType,
+ ["AlarmConditionType"] = ObjectTypeIds.AlarmConditionType,
+ ["LimitAlarmType"] = ObjectTypeIds.LimitAlarmType,
+ ["ExclusiveLimitAlarmType"] = ObjectTypeIds.ExclusiveLimitAlarmType,
+ ["NonExclusiveLimitAlarmType"] = ObjectTypeIds.NonExclusiveLimitAlarmType,
+ ["ExclusiveLevelAlarmType"] = ObjectTypeIds.ExclusiveLevelAlarmType,
+ ["NonExclusiveLevelAlarmType"] = ObjectTypeIds.NonExclusiveLevelAlarmType,
+ ["ExclusiveDeviationAlarmType"] = ObjectTypeIds.ExclusiveDeviationAlarmType,
+ ["NonExclusiveDeviationAlarmType"] = ObjectTypeIds.NonExclusiveDeviationAlarmType,
+ ["ExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.ExclusiveRateOfChangeAlarmType,
+ ["NonExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.NonExclusiveRateOfChangeAlarmType,
+ ["DiscreteAlarmType"] = ObjectTypeIds.DiscreteAlarmType,
+ ["OffNormalAlarmType"] = ObjectTypeIds.OffNormalAlarmType,
+ ["SystemOffNormalAlarmType"] = ObjectTypeIds.SystemOffNormalAlarmType,
+ ["TripAlarmType"] = ObjectTypeIds.TripAlarmType,
+ ["DiscrepancyAlarmType"] = ObjectTypeIds.DiscrepancyAlarmType,
+ ["InstrumentDiagnosticAlarmType"] = ObjectTypeIds.InstrumentDiagnosticAlarmType,
+ ["SystemDiagnosticAlarmType"] = ObjectTypeIds.SystemDiagnosticAlarmType,
+ ["CertificateExpirationAlarmType"] = ObjectTypeIds.CertificateExpirationAlarmType,
+ };
+
+ ///
+ /// Builds the event filter selecting the base event fields plus the
+ /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror,
+ /// and — when is non-empty and every requested
+ /// type maps to a standard A&C type — a server-side
+ /// WhereClause (OfType, OR'd) as a bandwidth optimisation (M2.4 / #8).
+ ///
+ ///
+ /// Conservative by design: if any requested type name cannot be mapped to
+ /// a standard NodeId, the WhereClause is omitted entirely
+ /// rather than partially applied — a partial server-side filter would silently drop
+ /// the unmapped types' events, and the server cannot send what it filtered out. The
+ /// client-side gate in DataConnectionActor enforces the full filter regardless, so
+ /// omitting the WhereClause only forgoes the bandwidth saving, never correctness.
+ ///
+ ///
+ /// The parsed condition-type filter (allow-all when empty).
+ /// The configured .
+ internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
{
var filter = new EventFilter();
foreach (var name in AlarmStateFields)
@@ -306,9 +354,48 @@ public class RealOpcUaClient : IOpcUaClient
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
+
+ ApplyServerSideTypeWhereClause(filter, conditionFilter);
return filter;
}
+ ///
+ /// Attaches an OfType(-OR'd) WhereClause to when every
+ /// requested condition type maps to a standard A&C type NodeId; otherwise leaves
+ /// the WhereClause empty (see rationale).
+ ///
+ private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter)
+ {
+ if (conditionFilter.IsEmpty)
+ return;
+
+ var typeIds = new List();
+ foreach (var name in conditionFilter.Names)
+ {
+ if (!KnownConditionTypeIds.TryGetValue(name, out var id))
+ return; // unmapped type → omit the WhereClause entirely (client gate covers it)
+ typeIds.Add(id);
+ }
+
+ if (typeIds.Count == 0)
+ return;
+
+ var where = filter.WhereClause;
+ if (typeIds.Count == 1)
+ {
+ where.Push(FilterOperator.OfType, typeIds[0]);
+ return;
+ }
+
+ // OR together each OfType element so an event of ANY listed type passes.
+ var element = where.Push(FilterOperator.OfType, typeIds[0]);
+ for (var i = 1; i < typeIds.Count; i++)
+ {
+ var next = where.Push(FilterOperator.OfType, typeIds[i]);
+ element = where.Push(FilterOperator.Or, element, next);
+ }
+ }
+
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
{
var path = new QualifiedNameCollection();
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs
new file mode 100644
index 00000000..ecf9e801
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs
@@ -0,0 +1,78 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+
+///
+/// Parsed native-alarm condition filter (M2.4 / #8).
+///
+///
+/// A source's conditionFilter is a comma-separated, case-insensitive list
+/// of alarm/condition type names, matched against
+/// . A null, blank, or
+/// all-empty list means "mirror every condition" (the historical behaviour),
+/// represented here by .
+///
+///
+///
+/// This is the authoritative client-side gate consulted in the
+/// DataConnectionActor routing path, so it applies uniformly across OPC UA
+/// (whose server-side WhereClause is only a bandwidth optimisation) and the
+/// MxGateway (whose single gateway-wide feed has no server-side filter at all).
+/// Parse once at subscribe time; is the hot-path check.
+///
+///
+public sealed class AlarmConditionFilter
+{
+ /// The shared allow-all instance (empty filter set).
+ public static readonly AlarmConditionFilter AllowAll = new(new HashSet(StringComparer.OrdinalIgnoreCase));
+
+ private readonly HashSet _names;
+
+ private AlarmConditionFilter(HashSet names) => _names = names;
+
+ /// true when no type names are configured — every condition is allowed.
+ public bool IsEmpty => _names.Count == 0;
+
+ /// The normalized (trimmed) type names, for the OPC UA server-side WhereClause optimisation.
+ public IReadOnlyCollection Names => _names;
+
+ ///
+ /// Parses a raw conditionFilter string into a normalized, case-insensitive
+ /// type-name set. null/blank/all-empty input yields an empty (allow-all) filter.
+ ///
+ /// The raw comma-separated filter string, or null.
+ /// A parsed ; never null.
+ public static AlarmConditionFilter Parse(string? conditionFilter)
+ {
+ if (string.IsNullOrWhiteSpace(conditionFilter))
+ return AllowAll;
+
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var raw in conditionFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ names.Add(raw);
+
+ return names.Count == 0 ? AllowAll : new AlarmConditionFilter(names);
+ }
+
+ ///
+ /// Returns true when should be delivered:
+ /// the filter is empty (allow all), the transition is a framing sentinel
+ /// (, which carries no condition
+ /// type and must never be swallowed or the snapshot swap never completes), or its
+ /// is in the configured set.
+ ///
+ /// The protocol-neutral transition to test.
+ /// true to deliver the transition; false to drop it.
+ public bool IsAllowed(NativeAlarmTransition transition)
+ {
+ if (_names.Count == 0)
+ return true;
+
+ // SnapshotComplete is pure framing (no condition payload) — never filter it.
+ if (transition.Kind == AlarmTransitionKind.SnapshotComplete)
+ return true;
+
+ return _names.Contains(transition.AlarmTypeName);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
index 7a786ed4..751f2fb1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
@@ -19,6 +19,13 @@
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
new file mode 100644
index 00000000..ba93e822
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
@@ -0,0 +1,76 @@
+using Opc.Ua;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// M2.4 (#8): the OPC UA EventFilter gains a server-side
+/// WhereClause as a bandwidth optimisation when a condition-type filter is present.
+/// The client-side gate in DataConnectionActor remains authoritative; these tests
+/// only pin the filter-shaping. No live server required — pure SDK object building.
+///
+public class RealOpcUaClientAlarmFilterTests
+{
+ [Fact]
+ public void BuildAlarmEventFilter_NoFilter_HasNoWhereClause()
+ {
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
+ Assert.NotEmpty(filter.SelectClauses);
+ Assert.Empty(filter.WhereClause.Elements);
+ }
+
+ [Fact]
+ public void BuildAlarmEventFilter_WithKnownTypes_BuildsNonEmptyWhereClause()
+ {
+ var parsed = AlarmConditionFilter.Parse("LimitAlarmType,DiscreteAlarmType");
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
+
+ Assert.NotEmpty(filter.WhereClause.Elements);
+ // Two known types → two OfType operands (OR'd when more than one).
+ var ofTypeCount = filter.WhereClause.Elements.Count(e => e.FilterOperator == FilterOperator.OfType);
+ Assert.Equal(2, ofTypeCount);
+ Assert.Contains(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.Or);
+ }
+
+ [Fact]
+ public void BuildAlarmEventFilter_SingleKnownType_BuildsSingleOfType_NoOr()
+ {
+ var parsed = AlarmConditionFilter.Parse("AlarmConditionType");
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
+
+ Assert.Single(filter.WhereClause.Elements);
+ Assert.Equal(FilterOperator.OfType, filter.WhereClause.Elements[0].FilterOperator);
+ }
+
+ [Fact]
+ public void BuildAlarmEventFilter_TypeMatchingIsCaseInsensitive()
+ {
+ var parsed = AlarmConditionFilter.Parse("limitalarmtype");
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
+ Assert.Single(filter.WhereClause.Elements, e => e.FilterOperator == FilterOperator.OfType);
+ }
+
+ [Fact]
+ public void BuildAlarmEventFilter_AllUnknownTypes_OmitsWhereClause()
+ {
+ // Custom/vendor type names we cannot map to standard NodeIds are skipped
+ // server-side; the client-side gate still enforces them. Omitting the
+ // WhereClause is the safe choice — a partial WhereClause would drop the
+ // unmapped types at the server and break correctness.
+ var parsed = AlarmConditionFilter.Parse("MyVendorCustomAlarm,AnotherCustomThing");
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
+ Assert.Empty(filter.WhereClause.Elements);
+ }
+
+ [Fact]
+ public void BuildAlarmEventFilter_MixedKnownAndUnknown_OmitsWhereClause()
+ {
+ // If ANY requested type can't be mapped, a server-side WhereClause would
+ // silently drop that type's events — so we omit the optimisation entirely
+ // and let the (authoritative) client gate do the filtering.
+ var parsed = AlarmConditionFilter.Parse("LimitAlarmType,MyVendorCustomAlarm");
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
+ Assert.Empty(filter.WhereClause.Elements);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs
new file mode 100644
index 00000000..b7589e35
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/AlarmConditionFilterTests.cs
@@ -0,0 +1,99 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
+
+///
+/// M2.4 (#8): the alarm conditionFilter is a comma-separated, case-insensitive
+/// list of condition type names. Blank = allow all. These tests pin the
+/// parse-once / IsAllowed predicate that the DataConnectionActor uses as the
+/// authoritative client-side gate.
+///
+public class AlarmConditionFilterTests
+{
+ private static NativeAlarmTransition Tx(string typeName,
+ AlarmTransitionKind kind = AlarmTransitionKind.Raise) =>
+ new("ref", "obj", typeName, kind,
+ new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
+ "cat", "desc", "msg", "", "", null, DateTimeOffset.UtcNow, "1", "0");
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(",")]
+ [InlineData(" , , ")]
+ public void NullOrBlankFilter_IsEmpty_AllowsEverything(string? filter)
+ {
+ var f = AlarmConditionFilter.Parse(filter);
+ Assert.True(f.IsEmpty);
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi")));
+ Assert.True(f.IsAllowed(Tx("anything-at-all")));
+ }
+
+ [Fact]
+ public void Parse_SplitsCommaSeparatedList()
+ {
+ var f = AlarmConditionFilter.Parse("AnalogLimit.Hi,DiscreteAlarm,AnalogLimit.Lo");
+ Assert.False(f.IsEmpty);
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi")));
+ Assert.True(f.IsAllowed(Tx("DiscreteAlarm")));
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Lo")));
+ Assert.False(f.IsAllowed(Tx("AnalogLimit.HiHi")));
+ }
+
+ [Fact]
+ public void IsAllowed_IsCaseInsensitive()
+ {
+ var f = AlarmConditionFilter.Parse("AnalogLimit.Hi");
+ Assert.True(f.IsAllowed(Tx("analoglimit.hi")));
+ Assert.True(f.IsAllowed(Tx("ANALOGLIMIT.HI")));
+ Assert.False(f.IsAllowed(Tx("DiscreteAlarm")));
+ }
+
+ [Fact]
+ public void Parse_TrimsWhitespaceAroundEachName()
+ {
+ var f = AlarmConditionFilter.Parse(" AnalogLimit.Hi ,\tDiscreteAlarm ");
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi")));
+ Assert.True(f.IsAllowed(Tx("DiscreteAlarm")));
+ }
+
+ [Fact]
+ public void Parse_DropsEmptyEntries_KeepsNonEmpty()
+ {
+ var f = AlarmConditionFilter.Parse("AnalogLimit.Hi,, ,DiscreteAlarm");
+ Assert.False(f.IsEmpty);
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi")));
+ Assert.True(f.IsAllowed(Tx("DiscreteAlarm")));
+ Assert.False(f.IsAllowed(Tx("")));
+ }
+
+ [Fact]
+ public void IsAllowed_NeverDropsSnapshotCompleteFramingSentinel()
+ {
+ // SnapshotComplete is a pure framing sentinel (empty AlarmTypeName) that
+ // drives the NativeAlarmActor's atomic snapshot swap. A type filter must
+ // never swallow it or the snapshot replay never completes.
+ var f = AlarmConditionFilter.Parse("AnalogLimit.Hi");
+ Assert.True(f.IsAllowed(Tx("", AlarmTransitionKind.SnapshotComplete)));
+ }
+
+ [Fact]
+ public void IsAllowed_FiltersReplayedSnapshotConditionsByType()
+ {
+ // Snapshot-kind transitions carry real conditions and ARE filtered.
+ var f = AlarmConditionFilter.Parse("AnalogLimit.Hi");
+ Assert.True(f.IsAllowed(Tx("AnalogLimit.Hi", AlarmTransitionKind.Snapshot)));
+ Assert.False(f.IsAllowed(Tx("DiscreteAlarm", AlarmTransitionKind.Snapshot)));
+ }
+
+ [Fact]
+ public void Names_ExposesNormalizedSet_ForServerSideOptimization()
+ {
+ var f = AlarmConditionFilter.Parse(" AnalogLimit.Hi , DiscreteAlarm ");
+ Assert.Equal(new[] { "AnalogLimit.Hi", "DiscreteAlarm" }, f.Names.OrderBy(n => n).ToArray());
+ Assert.Empty(AlarmConditionFilter.Parse(null).Names);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs
index bc321570..cd685897 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs
@@ -23,10 +23,27 @@ public class DataConnectionActorAlarmTests : TestKit
};
private static NativeAlarmTransition Raise(string sourceRef, string sourceObj) =>
- new(sourceRef, sourceObj, "AnalogLimit.Hi", AlarmTransitionKind.Raise,
+ Raise(sourceRef, sourceObj, "AnalogLimit.Hi");
+
+ private static NativeAlarmTransition Raise(string sourceRef, string sourceObj, string typeName,
+ AlarmTransitionKind kind = AlarmTransitionKind.Raise) =>
+ new(sourceRef, sourceObj, typeName, kind,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
+ private static (IDataConnection Adapter, Func Cb) BuildAlarmAdapter()
+ {
+ AlarmTransitionCallback? cb = null;
+ var adapter = Substitute.For();
+ adapter.ConnectAsync(Arg.Any>(), Arg.Any())
+ .Returns(Task.CompletedTask);
+ ((IAlarmSubscribableConnection)adapter)
+ .SubscribeAlarmsAsync(Arg.Any(), Arg.Any(),
+ Arg.Do(c => cb = c), Arg.Any())
+ .Returns(Task.FromResult("alarm-sub-1"));
+ return (adapter, () => cb);
+ }
+
[Fact]
public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber()
{
@@ -63,4 +80,119 @@ public class DataConnectionActorAlarmTests : TestKit
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
ExpectMsg(m => !m.Success && m.ErrorMessage != null);
}
+
+ // ── M2.4 (#8): conditionFilter is now applied client-side in the actor ──
+
+ [Fact]
+ public void SubscribeAlarms_WithTypeFilter_DeliversOnlyMatchingTypes()
+ {
+ var (adapter, getCb) = BuildAlarmAdapter();
+ var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
+ "conn", adapter, _options, _health, _factory, "OpcUa")));
+
+ actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01",
+ "AnalogLimit.Hi,AnalogLimit.Lo", DateTimeOffset.UtcNow));
+ ExpectMsg(m => m.Success);
+ var cb = getCb();
+ Assert.NotNull(cb);
+
+ // Non-matching type is dropped (no message delivered).
+ cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi"));
+ ExpectNoMsg(TimeSpan.FromMilliseconds(250));
+
+ // Matching type is delivered.
+ cb!(Raise("Tank01.Hi", "Tank01", "AnalogLimit.Hi"));
+ ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi");
+ }
+
+ [Fact]
+ public void SubscribeAlarms_WithNullFilter_DeliversAllTypes()
+ {
+ var (adapter, getCb) = BuildAlarmAdapter();
+ var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
+ "conn", adapter, _options, _health, _factory, "OpcUa")));
+
+ actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
+ ExpectMsg(m => m.Success);
+ var cb = getCb();
+ Assert.NotNull(cb);
+
+ cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi"));
+ ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.HiHi");
+ cb!(Raise("Tank01.Lo", "Tank01", "DiscreteAlarm"));
+ ExpectMsg(u => u.Transition.AlarmTypeName == "DiscreteAlarm");
+ }
+
+ [Fact]
+ public void SubscribeAlarms_FilterMatch_IgnoresCaseAndWhitespace()
+ {
+ var (adapter, getCb) = BuildAlarmAdapter();
+ var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
+ "conn", adapter, _options, _health, _factory, "OpcUa")));
+
+ actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01",
+ " analoglimit.hi ,\tDISCRETEALARM ", DateTimeOffset.UtcNow));
+ ExpectMsg(m => m.Success);
+ var cb = getCb();
+ Assert.NotNull(cb);
+
+ cb!(Raise("Tank01.Hi", "Tank01", "AnalogLimit.Hi")); // case differs from filter
+ ExpectMsg(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi");
+ cb!(Raise("Tank01.Disc", "Tank01", "DiscreteAlarm"));
+ ExpectMsg(u => u.Transition.AlarmTypeName == "DiscreteAlarm");
+ cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi")); // not listed
+ ExpectNoMsg(TimeSpan.FromMilliseconds(250));
+ }
+
+ [Fact]
+ public void SubscribeAlarms_GatewayWideFeed_IsFilteredClientSide()
+ {
+ // MxGateway has no server-side filter: its adapter opens ONE gateway-wide
+ // feed and the actor is the authoritative gate. A filtered source must
+ // only see its own matching types even though the feed carries everything.
+ var (adapter, getCb) = BuildAlarmAdapter();
+ var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
+ "conn", adapter, _options, _health, _factory, "MxGateway")));
+
+ actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Reactor",
+ "HighTemp", DateTimeOffset.UtcNow));
+ ExpectMsg(m => m.Success);
+ var cb = getCb();
+ Assert.NotNull(cb);
+
+ // Gateway-wide feed delivers a transition for a different source object —
+ // dropped by source routing.
+ cb!(Raise("Pump.Fault", "Pump", "HighTemp"));
+ ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ // Right source, wrong type — dropped by the client-side type gate.
+ cb!(Raise("Reactor.LowTemp", "Reactor", "LowTemp"));
+ ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ // Right source, right type — delivered.
+ cb!(Raise("Reactor.HighTemp", "Reactor", "HighTemp"));
+ ExpectMsg(u =>
+ u.Transition.SourceObjectReference == "Reactor" && u.Transition.AlarmTypeName == "HighTemp");
+ }
+
+ [Fact]
+ public void SubscribeAlarms_WithFilter_StillForwardsSnapshotCompleteSentinel()
+ {
+ // The SnapshotComplete framing sentinel (empty AlarmTypeName) must survive
+ // the type gate so the NativeAlarmActor's snapshot swap can complete.
+ var (adapter, getCb) = BuildAlarmAdapter();
+ var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
+ "conn", adapter, _options, _health, _factory, "OpcUa")));
+
+ actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01",
+ "AnalogLimit.Hi", DateTimeOffset.UtcNow));
+ ExpectMsg(m => m.Success);
+ var cb = getCb();
+ Assert.NotNull(cb);
+
+ // Snapshot-complete sentinel: empty source refs (the framing marker) but
+ // routed because every subscriber receives it; never type-filtered.
+ cb!(new NativeAlarmTransition("Tank01", "Tank01", "", AlarmTransitionKind.SnapshotComplete,
+ new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
+ "", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""));
+ ExpectMsg(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
+ }
}