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:
Joseph Doherty
2026-06-15 14:16:10 -04:00
parent de375ff7ea
commit 8825df56be
8 changed files with 508 additions and 7 deletions
@@ -99,8 +99,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// routed to subscribers (NativeAlarmActors) by source-object reference.
/// <summary>sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count.</summary>
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();
/// <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>
private readonly Dictionary<string, string> _alarmSubscriptionIds = new();
/// <summary>sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight.</summary>
@@ -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)
{
@@ -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);
}
}
@@ -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
}
/// <summary>
/// Builds the event filter selecting the base event fields plus the
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror.
/// Maps the standard OPC UA Alarms &amp; Conditions type names (case-insensitive)
/// 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>
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&amp;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();
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;
}
/// <summary>
/// Attaches an OfType(-OR'd) WhereClause to <paramref name="filter"/> when every
/// requested condition type maps to a standard A&amp;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)
{
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);
}
}
@@ -19,6 +19,13 @@
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
</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>
<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" />