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:
@@ -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 & 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&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&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();
|
||||
|
||||
Reference in New Issue
Block a user