fix(dcl): apply native-alarm conditionFilter (client-side gate + OPC UA WhereClause) (#8)
conditionFilter was plumbed end-to-end but applied nowhere — a filtered source silently mirrored all conditions. Define the filter as a comma-separated, case-insensitive list of condition type names (blank = all); enforce it authoritatively client-side in DataConnectionActor routing (uniform across OPC UA + MxGateway) and, for OPC UA, additionally build a server-side EventFilter WhereClause as a bandwidth optimization.
This commit is contained in:
@@ -99,8 +99,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
// routed to subscribers (NativeAlarmActors) by source-object reference.
|
// routed to subscribers (NativeAlarmActors) by source-object reference.
|
||||||
/// <summary>sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count.</summary>
|
/// <summary>sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count.</summary>
|
||||||
private readonly Dictionary<string, HashSet<IActorRef>> _alarmSourceSubscribers = new();
|
private readonly Dictionary<string, HashSet<IActorRef>> _alarmSourceSubscribers = new();
|
||||||
/// <summary>sourceReference → optional condition filter (first subscriber wins).</summary>
|
/// <summary>sourceReference → raw condition filter string passed to the adapter (first subscriber wins).</summary>
|
||||||
private readonly Dictionary<string, string?> _alarmSourceFilter = new();
|
private readonly Dictionary<string, string?> _alarmSourceFilter = new();
|
||||||
|
/// <summary>
|
||||||
|
/// sourceReference → parsed condition-type predicate (M2.4 / #8). The authoritative
|
||||||
|
/// client-side gate in <see cref="HandleAlarmTransitionReceived"/>; applies uniformly
|
||||||
|
/// across OPC UA and the gateway-wide MxGateway feed.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, AlarmConditionFilter> _alarmSourceFilterPredicate = new();
|
||||||
/// <summary>sourceReference → adapter alarm subscription id.</summary>
|
/// <summary>sourceReference → adapter alarm subscription id.</summary>
|
||||||
private readonly Dictionary<string, string> _alarmSubscriptionIds = new();
|
private readonly Dictionary<string, string> _alarmSubscriptionIds = new();
|
||||||
/// <summary>sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight.</summary>
|
/// <summary>sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight.</summary>
|
||||||
@@ -1480,6 +1486,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
}
|
}
|
||||||
subs.Add(subscriber);
|
subs.Add(subscriber);
|
||||||
_alarmSourceFilter[request.SourceReference] = request.ConditionFilter;
|
_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
|
// If the adapter feed for this source is already (being) established, the
|
||||||
// existing subscription serves the new subscriber too.
|
// existing subscription serves the new subscriber too.
|
||||||
@@ -1546,6 +1555,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
if (!match)
|
if (!match)
|
||||||
continue;
|
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)
|
foreach (var sub in subs)
|
||||||
{
|
{
|
||||||
if (notified.Add(sub))
|
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.
|
// No subscribers remain for this source — tear down the adapter feed.
|
||||||
_alarmSourceSubscribers.Remove(request.SourceReference);
|
_alarmSourceSubscribers.Remove(request.SourceReference);
|
||||||
_alarmSourceFilter.Remove(request.SourceReference);
|
_alarmSourceFilter.Remove(request.SourceReference);
|
||||||
|
_alarmSourceFilterPredicate.Remove(request.SourceReference);
|
||||||
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
|
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
|
||||||
_adapter is IAlarmSubscribableConnection alarmable)
|
_adapter is IAlarmSubscribableConnection alarmable)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,7 +163,11 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
|
|||||||
_alarmCts = new CancellationTokenSource();
|
_alarmCts = new CancellationTokenSource();
|
||||||
var token = _alarmCts.Token;
|
var token = _alarmCts.Token;
|
||||||
var client = _client!;
|
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);
|
_ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,9 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
MonitoringMode = MonitoringMode.Reporting,
|
MonitoringMode = MonitoringMode.Reporting,
|
||||||
SamplingInterval = 0,
|
SamplingInterval = 0,
|
||||||
QueueSize = 1000,
|
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) =>
|
item.Notification += (_, e) =>
|
||||||
@@ -289,10 +291,56 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the event filter selecting the base event fields plus the
|
/// Maps the standard OPC UA Alarms & Conditions type names (case-insensitive)
|
||||||
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror.
|
/// to their well-known <see cref="ObjectTypeIds"/> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static EventFilter BuildAlarmEventFilter()
|
private static readonly IReadOnlyDictionary<string, NodeId> KnownConditionTypeIds =
|
||||||
|
new Dictionary<string, NodeId>(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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the event filter selecting the base event fields plus the
|
||||||
|
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror,
|
||||||
|
/// and — when <paramref name="conditionFilter"/> is non-empty and every requested
|
||||||
|
/// type maps to a standard A&C type — a server-side <see cref="ContentFilter"/>
|
||||||
|
/// WhereClause (OfType, OR'd) as a bandwidth optimisation (M2.4 / #8).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Conservative by design: if <em>any</em> requested type name cannot be mapped to
|
||||||
|
/// a standard <see cref="ObjectTypeIds"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="conditionFilter">The parsed condition-type filter (allow-all when empty).</param>
|
||||||
|
/// <returns>The configured <see cref="EventFilter"/>.</returns>
|
||||||
|
internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
|
||||||
{
|
{
|
||||||
var filter = new EventFilter();
|
var filter = new EventFilter();
|
||||||
foreach (var name in AlarmStateFields)
|
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.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
|
||||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
|
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
|
||||||
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
|
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
|
||||||
|
|
||||||
|
ApplyServerSideTypeWhereClause(filter, conditionFilter);
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches an OfType(-OR'd) WhereClause to <paramref name="filter"/> when every
|
||||||
|
/// requested condition type maps to a standard A&C type NodeId; otherwise leaves
|
||||||
|
/// the WhereClause empty (see <see cref="BuildAlarmEventFilter"/> rationale).
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter)
|
||||||
|
{
|
||||||
|
if (conditionFilter.IsEmpty)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var typeIds = new List<NodeId>();
|
||||||
|
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)
|
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
|
||||||
{
|
{
|
||||||
var path = new QualifiedNameCollection();
|
var path = new QualifiedNameCollection();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed native-alarm condition filter (M2.4 / #8).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// A source's <c>conditionFilter</c> is a comma-separated, case-insensitive list
|
||||||
|
/// of alarm/condition <em>type names</em>, matched against
|
||||||
|
/// <see cref="NativeAlarmTransition.AlarmTypeName"/>. A <c>null</c>, blank, or
|
||||||
|
/// all-empty list means "mirror every condition" (the historical behaviour),
|
||||||
|
/// represented here by <see cref="IsEmpty"/>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This is the authoritative <em>client-side</em> gate consulted in the
|
||||||
|
/// <c>DataConnectionActor</c> routing path, so it applies uniformly across OPC UA
|
||||||
|
/// (whose server-side <c>WhereClause</c> 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; <see cref="IsAllowed"/> is the hot-path check.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmConditionFilter
|
||||||
|
{
|
||||||
|
/// <summary>The shared allow-all instance (empty filter set).</summary>
|
||||||
|
public static readonly AlarmConditionFilter AllowAll = new(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private readonly HashSet<string> _names;
|
||||||
|
|
||||||
|
private AlarmConditionFilter(HashSet<string> names) => _names = names;
|
||||||
|
|
||||||
|
/// <summary><c>true</c> when no type names are configured — every condition is allowed.</summary>
|
||||||
|
public bool IsEmpty => _names.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>The normalized (trimmed) type names, for the OPC UA server-side WhereClause optimisation.</summary>
|
||||||
|
public IReadOnlyCollection<string> Names => _names;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a raw <c>conditionFilter</c> string into a normalized, case-insensitive
|
||||||
|
/// type-name set. <c>null</c>/blank/all-empty input yields an empty (allow-all) filter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="conditionFilter">The raw comma-separated filter string, or <c>null</c>.</param>
|
||||||
|
/// <returns>A parsed <see cref="AlarmConditionFilter"/>; never <c>null</c>.</returns>
|
||||||
|
public static AlarmConditionFilter Parse(string? conditionFilter)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(conditionFilter))
|
||||||
|
return AllowAll;
|
||||||
|
|
||||||
|
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var raw in conditionFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
names.Add(raw);
|
||||||
|
|
||||||
|
return names.Count == 0 ? AllowAll : new AlarmConditionFilter(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> when <paramref name="transition"/> should be delivered:
|
||||||
|
/// the filter is empty (allow all), the transition is a framing sentinel
|
||||||
|
/// (<see cref="AlarmTransitionKind.SnapshotComplete"/>, which carries no condition
|
||||||
|
/// type and must never be swallowed or the snapshot swap never completes), or its
|
||||||
|
/// <see cref="NativeAlarmTransition.AlarmTypeName"/> is in the configured set.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="transition">The protocol-neutral transition to test.</param>
|
||||||
|
/// <returns><c>true</c> to deliver the transition; <c>false</c> to drop it.</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -19,6 +19,13 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Exposes internal alarm-filter shaping (RealOpcUaClient.BuildAlarmEventFilter)
|
||||||
|
to the test assembly so the server-side WhereClause can be unit-tested
|
||||||
|
without a live OPC UA server (M2.4 / #8). -->
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||||
|
|||||||
+76
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M2.4 (#8): the OPC UA EventFilter gains a server-side <see cref="ContentFilter"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-1
@@ -23,10 +23,27 @@ public class DataConnectionActorAlarmTests : TestKit
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static NativeAlarmTransition Raise(string sourceRef, string sourceObj) =>
|
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),
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
|
||||||
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
|
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
|
||||||
|
|
||||||
|
private static (IDataConnection Adapter, Func<AlarmTransitionCallback?> Cb) BuildAlarmAdapter()
|
||||||
|
{
|
||||||
|
AlarmTransitionCallback? cb = null;
|
||||||
|
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
||||||
|
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
((IAlarmSubscribableConnection)adapter)
|
||||||
|
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult("alarm-sub-1"));
|
||||||
|
return (adapter, () => cb);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber()
|
public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber()
|
||||||
{
|
{
|
||||||
@@ -63,4 +80,119 @@ public class DataConnectionActorAlarmTests : TestKit
|
|||||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
|
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
|
||||||
ExpectMsg<SubscribeAlarmsResponse>(m => !m.Success && m.ErrorMessage != null);
|
ExpectMsg<SubscribeAlarmsResponse>(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<SubscribeAlarmsResponse>(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<NativeAlarmTransitionUpdate>(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<SubscribeAlarmsResponse>(m => m.Success);
|
||||||
|
var cb = getCb();
|
||||||
|
Assert.NotNull(cb);
|
||||||
|
|
||||||
|
cb!(Raise("Tank01.HiHi", "Tank01", "AnalogLimit.HiHi"));
|
||||||
|
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "AnalogLimit.HiHi");
|
||||||
|
cb!(Raise("Tank01.Lo", "Tank01", "DiscreteAlarm"));
|
||||||
|
ExpectMsg<NativeAlarmTransitionUpdate>(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<SubscribeAlarmsResponse>(m => m.Success);
|
||||||
|
var cb = getCb();
|
||||||
|
Assert.NotNull(cb);
|
||||||
|
|
||||||
|
cb!(Raise("Tank01.Hi", "Tank01", "AnalogLimit.Hi")); // case differs from filter
|
||||||
|
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.AlarmTypeName == "AnalogLimit.Hi");
|
||||||
|
cb!(Raise("Tank01.Disc", "Tank01", "DiscreteAlarm"));
|
||||||
|
ExpectMsg<NativeAlarmTransitionUpdate>(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<SubscribeAlarmsResponse>(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<NativeAlarmTransitionUpdate>(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<SubscribeAlarmsResponse>(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<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user