Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/AlarmConditionFilter.cs
T
Joseph Doherty 8825df56be 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.
2026-06-15 14:16:10 -04:00

79 lines
3.5 KiB
C#

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);
}
}