diff --git a/docs/AlarmsAndConditions.md b/docs/AlarmsAndConditions.md index fe9a99acaf..1ea079b806 100644 --- a/docs/AlarmsAndConditions.md +++ b/docs/AlarmsAndConditions.md @@ -41,6 +41,7 @@ For the formal model, see | Latched + Reset | `SetLatchedState` + auto from `SetActiveState` | `AlarmClient.ResetAsync` | | Shelve / Unshelve | `SetShelvingState` | `AlarmClient.TimedShelveAsync` / `OneShotShelveAsync` / `UnshelveAsync` | | Re-alarm | `ProcessReAlarm` | n/a (server timer) | +| Filtered retain | `ConditionState.SupportsFilteredRetain` | n/a (per-subscription) | | Alarm groups | `AlarmGroup`, `AlarmSuppressionEngine` | `AlarmClient.GetGroupMembershipsAsync` | | Alarm rate | `AlarmRateTracker` | read `AlarmMetricsType` attributes | | Refresh state | `Server.ConditionRefresh` (server-driven) | `AlarmClient.ConditionRefreshAsync` / `ConditionRefresh2Async` | @@ -199,6 +200,41 @@ alarm.SetSuppressedState(context, false); // SuppressedOrShelved stays true alarm.SetOutOfServiceState(context, false); // SuppressedOrShelved = false ``` +### Filtered retain + +Part 9 B.1.4 lets a server give each client a Retain flag that takes +that client's event filter into account. With filtered retain on, a +condition that drops out of the scope of a client's where clause +reports one final event — so that client sees `Retain = false` even +though nothing changed on the server, and the alarm stays active for +everyone else. + +Opt a condition in by setting `SupportsFilteredRetain`: + +```csharp +alarm.SupportsFilteredRetain = + PropertyState.With(alarm, value: true); +``` + +Notes on the shape of that property: + +* Part 9 provides `SupportsFilteredRetain` **on the ConditionType + only** — the standard nodeset declares no modelling rule for it, so + condition instances do not carry it as a child. The C# property is + therefore the server side switch, not an address space node: + `FindChild(BrowseNames.SupportsFilteredRetain)` on an instance + returns null by design. Clients read the flag from the type node, + which the generated address space already builds. +* Branches inherit the parent's setting — `CreateBranch` copies it, + since nothing that walks the instance children would. +* Each condition and branch is tracked separately, so one branch + leaving filter scope does not affect its siblings or its parent. +* `ConditionRefresh` participates: refreshed conditions are evaluated + against the where clause and re-prime the tracking. +* Changing a monitored item's where clause with `ModifyMonitoredItems` + discards the tracking, because it describes the *previous* filter. + Durable subscriptions carry it across a restart. + ### Alarm groups and first-in-group `src/Opc.Ua.Server/Alarms/AlarmGroup.cs` wraps a generated diff --git a/src/Opc.Ua.Core.Types/State/ConditionState.cs b/src/Opc.Ua.Core.Types/State/ConditionState.cs index 00085ccefb..eacc5fffdf 100644 --- a/src/Opc.Ua.Core.Types/State/ConditionState.cs +++ b/src/Opc.Ua.Core.Types/State/ConditionState.cs @@ -50,7 +50,28 @@ namespace Opc.Ua AddComment?.OnCall = OnAddCommentCalled; } - /// + /// + /// Opts this condition into filtered retain (see OPC UA Part 9, B.1.4): when the + /// value is true, a monitored item reports one final event as the condition leaves + /// the scope of that client's where clause, so the client sees Retain go false even + /// though the condition is unchanged on the server. + /// + /// + /// Part 9 states that the SupportsFilteredRetain Property "is only provided on the + /// ConditionType", and the standard nodeset accordingly declares no modelling rule + /// for it, so condition instances do not carry it as an address space child. + /// This property is therefore deliberately not part of the instance child hierarchy: + /// it is not returned by and cannot be reached + /// through FindChild(BrowseNames.SupportsFilteredRetain). It is the server + /// side switch that mirrors, per condition, what the type node advertises. A server + /// that wants clients to browse the flag exposes it on its ConditionType node, which + /// the generated address space already builds. + /// + /// Because the property is not a child, nothing that copies a condition by walking + /// its children carries it. copies it explicitly, so + /// branches inherit the parent's setting. + /// + /// public PropertyState? SupportsFilteredRetain { get => m_supportsFilteredRetain; @@ -177,6 +198,10 @@ namespace Opc.Ua branchedAlarm.Initialize(context, this); branchedAlarm.BranchId!.Value = branchId; // ConditionState.Initialize creates BranchId branchedAlarm.AutoReportStateChanges = AutoReportStateChanges; + // SupportsFilteredRetain is not an instance child, so Initialize does not + // carry it over with the rest of the condition. Copy it here, otherwise + // filtered retain would be silently off for every branch. + CopySupportsFilteredRetain(context, branchedAlarm); branchedAlarm.ReportStateChange(context, false); string postEventId = branchedAlarm.EventId!.Value.ToHexString(); // ConditionState.Initialize creates EventId @@ -192,6 +217,31 @@ namespace Opc.Ua return state; } + /// + /// Gives the branch its own copy of . + /// + /// + /// The flag is not part of the instance child hierarchy - see the remarks on + /// - so NodeState.Initialize + /// does not carry it across when the branch is built from its parent. The branch + /// gets a copy rather than the parent's instance so the two nodes stay separate + /// address space objects. + /// + private void CopySupportsFilteredRetain(ISystemContext context, ConditionState branch) + { + PropertyState? source = SupportsFilteredRetain; + + if (source == null) + { + branch.SupportsFilteredRetain = null; + return; + } + + PropertyState copy = PropertyState.With(branch); + copy.Create(context, source); + branch.SupportsFilteredRetain = copy; + } + /// /// Creates a new instance of the current condition type for branching. /// Override in derived types to avoid reflection-based instantiation. diff --git a/src/Opc.Ua.Redundancy.Server/Subscriptions/SharedKeyValueSubscriptionStore.cs b/src/Opc.Ua.Redundancy.Server/Subscriptions/SharedKeyValueSubscriptionStore.cs index 81111f8492..7688032fbf 100644 --- a/src/Opc.Ua.Redundancy.Server/Subscriptions/SharedKeyValueSubscriptionStore.cs +++ b/src/Opc.Ua.Redundancy.Server/Subscriptions/SharedKeyValueSubscriptionStore.cs @@ -984,7 +984,8 @@ namespace Opc.Ua.Redundancy.Server SourceSamplingInterval = item.SourceSamplingInterval, SubscriptionId = item.SubscriptionId, TimestampsToReturn = item.TimestampsToReturn, - TypeMask = item.TypeMask + TypeMask = item.TypeMask, + FilteredRetainConditionIds = item.FilteredRetainConditionIds }; } @@ -1268,6 +1269,10 @@ namespace Opc.Ua.Redundancy.Server encoder.WriteBoolean(null, item.IsDeleted); encoder.WriteBoolean(null, item.IsDetached); } + if (version >= FilteredRetainDefinitionFormatVersion) + { + encoder.WriteStringArray(null, item.FilteredRetainConditionIds); + } } private static StoredMonitoredItem DecodeMonitoredItem(BinaryDecoder decoder, int version) @@ -1309,6 +1314,11 @@ namespace Opc.Ua.Redundancy.Server item.IsDeleted = decoder.ReadBoolean(null); item.IsDetached = decoder.ReadBoolean(null); } + if (version >= FilteredRetainDefinitionFormatVersion) + { + // the keys are written by the monitored item and never null. + item.FilteredRetainConditionIds = decoder.ReadStringArray(null)!; + } return item; } @@ -1391,7 +1401,8 @@ namespace Opc.Ua.Redundancy.Server private const int LegacyDefinitionFormatVersion = 1; private const int LifecycleStateDefinitionFormatVersion = 2; - private const int DefinitionFormatVersion = LifecycleStateDefinitionFormatVersion; + private const int FilteredRetainDefinitionFormatVersion = 3; + private const int DefinitionFormatVersion = FilteredRetainDefinitionFormatVersion; private const int DefinitionSnapshotManifestFormatVersion = 1; private const int ContinuationPointFormatVersion = 1; private const int LegacyRetransmissionStateFormatVersion = 1; diff --git a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs index 8802e42a9e..4b5a68f8f1 100644 --- a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs +++ b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs @@ -320,6 +320,17 @@ namespace Opc.Ua.Server m_lastValue = storedMonitoredItem.LastValue; MonitoredItemType = storedMonitoredItem.TypeMask; + // without this the first transition out of filter scope after a restart is + // dropped, because the item would not know the client had been told about the + // condition. + ArrayOf filteredRetainConditionIds = + storedMonitoredItem.FilteredRetainConditionIds; + + if (!filteredRetainConditionIds.IsEmpty) + { + m_filteredRetainConditionIds = [.. filteredRetainConditionIds]; + } + // create aggregate calculator. if (storedMonitoredItem.FilterToUse is ServerAggregateFilter aggregateFilter) { @@ -924,10 +935,14 @@ namespace Opc.Ua.Server ClientHandle = clientHandle; m_discardOldest = discardOldest; + MonitoringFilter? previousFilterToUse = m_filterToUse; + Filter = originalFilter; m_filterToUse = filterToUse; m_cachedDataChangeFilter = filterToUse as DataChangeFilter; + DiscardFilteredRetainStateOnWhereClauseChange(previousFilterToUse, filterToUse); + if (range != null) { m_range = range.High - range.Low; @@ -1348,6 +1363,22 @@ namespace Opc.Ua.Server /// /// Determines whether an event can be sent with SupportsFilteredRetain in consideration. /// + /// + /// Filtered retain (OPC UA Part 9, B.1.4) lets a condition report one final event as it + /// leaves the scope of this client's where clause, so the client sees Retain go false + /// even though the condition itself is unchanged on the server. The item therefore + /// remembers which conditions currently pass its where clause; a condition that drops + /// out of that set is delivered once more and then forgotten. + /// + /// The condition behind is resolved from either an + /// - the shape ReportEvent produces - or a + /// handed over directly, which is what + /// ConditionState.ConditionRefresh puts into the refresh event list. Any other + /// cannot be mapped onto a condition, so a server that + /// queues its own filter targets falls back to plain where clause evaluation and + /// filtered retain has no effect for those events. + /// + /// protected bool CanSendFilteredAlarm( IFilterContext context, EventFilter filter, @@ -1355,53 +1386,69 @@ namespace Opc.Ua.Server { bool passedFilter = filter.WhereClause.Evaluate(context, instance); - ConditionState? alarmCondition = null; - NodeId conditionId = default; - if (instance is InstanceStateSnapshot instanceStateSnapshot) - { - alarmCondition = instanceStateSnapshot.Handle as ConditionState; + ConditionState? alarmCondition = GetFilteredRetainCondition(instance); - if (alarmCondition != null && - alarmCondition.SupportsFilteredRetain != null && - alarmCondition.SupportsFilteredRetain.Value && - !filter.SelectClauses.IsNull) - { - conditionId = alarmCondition.NodeId; - } + if (alarmCondition == null || filter.SelectClauses.IsNull) + { + return passedFilter; } - bool canSend = passedFilter; + HashSet conditionIds = GetFilteredRetainConditionIds(); + string key = GetFilteredRetainKey(alarmCondition); - // ConditionId is valid only if FilteredRetain is set for the alarm condition - if (!conditionId.IsNull && alarmCondition != null) + // the key is present exactly while the condition passed the where clause the + // last time it was evaluated, so removing it here both consumes the entry for + // the transition out of scope and clears the way for re-priming it below. + bool wasInFilterScope = conditionIds.Remove(key); + + if (passedFilter) { - HashSet conditionIds = GetFilteredRetainConditionIds(); - - string key = conditionId.ToString(); - - bool saved = conditionIds.Contains(key); - - if (saved) - { - conditionIds.Remove(key); - } - - if (passedFilter) - { - // Archie - December 17 2024 - // Requires discussion with Part 9 Editor - // if (alarmCondition.Retain.Value) - { - conditionIds.Add(key); - } - } - else if (saved) - { - canSend = true; - } + // Archie - December 17 2024 + // Whether the condition should only be tracked while Retain is true is an + // open question with the Part 9 editor; it is tracked in + // https://github.com/OPCFoundation/UA-.NETStandard/issues/4370. Until that + // is settled a condition is tracked whenever it passes the where clause, + // which means it always produces one trailing event on the way out - even + // if Retain was already false when it passed. + conditionIds.Add(key); + return true; } - return canSend; + // out of scope now: send the trailing event if it was in scope before. + return wasInFilterScope; + } + + /// + /// Returns the condition that reports on when that + /// condition opted into filtered retain, otherwise null. + /// + private static ConditionState? GetFilteredRetainCondition(IFilterTarget instance) + { + ConditionState? condition = instance switch + { + InstanceStateSnapshot snapshot => snapshot.Handle as ConditionState, + ConditionState state => state, + _ => null + }; + + return condition?.SupportsFilteredRetain?.Value == true ? condition : null; + } + + /// + /// Builds the key a condition is tracked under. + /// + /// + /// A branch shares its parent's NodeId - branches are told apart by BranchId - so the + /// NodeId alone would make a condition and all of its branches contend for a single + /// entry, and one branch leaving filter scope would consume the entry another branch + /// relies on. + /// + private static string GetFilteredRetainKey(ConditionState condition) + { + return Utils.Format( + "{0}|{1}", + condition.NodeId, + condition.BranchId?.Value ?? default); } private HashSet GetFilteredRetainConditionIds() @@ -1409,6 +1456,41 @@ namespace Opc.Ua.Server return m_filteredRetainConditionIds ??= []; } + /// + /// Drops the filtered retain bookkeeping when the where clause it was derived from + /// is replaced. + /// + /// + /// Every entry means "this condition passed the previous where clause". Keeping + /// those across a ModifyMonitoredItems that changes the where clause would produce + /// a trailing event against a filter that never saw the condition pass. The select + /// clauses do not take part in the decision, since they only shape the fields of an + /// event that is being sent anyway. + /// + /// A monitoring mode change deliberately keeps the state. A disabled item does not + /// evaluate events at all, so the entries still describe what the client was last + /// told, and the trailing event is owed to it once reporting resumes. + /// + /// + private void DiscardFilteredRetainStateOnWhereClauseChange( + MonitoringFilter? previousFilter, + MonitoringFilter? newFilter) + { + if (m_filteredRetainConditionIds == null || + m_filteredRetainConditionIds.Count == 0) + { + return; + } + + ContentFilter? previousWhereClause = (previousFilter as EventFilter)?.WhereClause; + ContentFilter? newWhereClause = (newFilter as EventFilter)?.WhereClause; + + if (!Utils.IsEqual(previousWhereClause, newWhereClause)) + { + m_filteredRetainConditionIds.Clear(); + } + } + /// /// Used to check whether the item is ready to sample. /// @@ -1847,7 +1929,10 @@ namespace Opc.Ua.Server Range = m_range, TimestampsToReturn = m_timestampsToReturn, TypeMask = MonitoredItemType, - ParsedIndexRange = m_parsedIndexRange + ParsedIndexRange = m_parsedIndexRange, + FilteredRetainConditionIds = m_filteredRetainConditionIds?.Count > 0 + ? [.. m_filteredRetainConditionIds] + : ArrayOf.Null }; } } diff --git a/src/Opc.Ua.Server/Subscription/Persistence/IStoredMonitoredItem.cs b/src/Opc.Ua.Server/Subscription/Persistence/IStoredMonitoredItem.cs index 0db71bfc42..87f0f4701d 100644 --- a/src/Opc.Ua.Server/Subscription/Persistence/IStoredMonitoredItem.cs +++ b/src/Opc.Ua.Server/Subscription/Persistence/IStoredMonitoredItem.cs @@ -166,6 +166,19 @@ namespace Opc.Ua.Server /// int TypeMask { get; set; } + /// + /// The conditions that currently pass the item's event filter and are tracked for + /// filtered retain (OPC UA Part 9, B.1.4). + /// + /// + /// Without this state a durable subscription drops the first transition out of + /// filter scope after a restart, because the item no longer knows that the client + /// had ever been told about the condition. The entries are opaque keys built by the + /// monitored item; a store only has to round-trip them. A null or empty array both + /// mean nothing is being tracked. + /// + ArrayOf FilteredRetainConditionIds { get; set; } + /// /// An optional data-change queue pre-hydrated by an asynchronous /// during restore. Runtime-only; never persisted. diff --git a/src/Opc.Ua.Server/Subscription/Persistence/StoredMonitoredItem.cs b/src/Opc.Ua.Server/Subscription/Persistence/StoredMonitoredItem.cs index 56b8eaea94..88bfbfce20 100644 --- a/src/Opc.Ua.Server/Subscription/Persistence/StoredMonitoredItem.cs +++ b/src/Opc.Ua.Server/Subscription/Persistence/StoredMonitoredItem.cs @@ -110,6 +110,9 @@ namespace Opc.Ua.Server /// public NumericRange ParsedIndexRange { get; set; } + /// + public ArrayOf FilteredRetainConditionIds { get; set; } + /// public IDataChangeMonitoredItemQueue? RestoredDataChangeQueue { get; set; } diff --git a/tests/Opc.Ua.Redundancy.Server.Tests/Subscriptions/SharedKeyValueSubscriptionStoreTests.cs b/tests/Opc.Ua.Redundancy.Server.Tests/Subscriptions/SharedKeyValueSubscriptionStoreTests.cs index ebe667ae35..04fd943e79 100644 --- a/tests/Opc.Ua.Redundancy.Server.Tests/Subscriptions/SharedKeyValueSubscriptionStoreTests.cs +++ b/tests/Opc.Ua.Redundancy.Server.Tests/Subscriptions/SharedKeyValueSubscriptionStoreTests.cs @@ -64,6 +64,9 @@ namespace Opc.Ua.Server.Tests.Redundancy { private const ushort NamespaceIndex = 2; + private static readonly string[] s_filteredRetainConditionIds = + ["ns=2;s=Alarm|", "ns=2;s=Alarm|i=7"]; + [Test] public async Task StoreAndRestoreRoundTripsDefinitionAsync() { @@ -109,6 +112,57 @@ namespace Opc.Ua.Server.Tests.Redundancy }); } + [Test] + public async Task StoreAndRestoreRoundTripsFilteredRetainConditionIdsAsync() + { + using var kv = new InMemorySharedKeyValueStore(); + SharedKeyValueSubscriptionStore active = CreateStore(kv); + SharedKeyValueSubscriptionStore backup = CreateStore(kv); + StoredSubscription expected = NewSubscription(103, 13); + var expectedItem = (StoredMonitoredItem)expected.MonitoredItems.Single(); + expectedItem.FilteredRetainConditionIds = [.. s_filteredRetainConditionIds]; + + await active.StoreSubscriptionsAsync([expected]).ConfigureAwait(false); + RestoreSubscriptionResult result = await backup.RestoreSubscriptionsAsync().ConfigureAwait(false); + + Assert.That(result.Success, Is.True); + var actual = (StoredMonitoredItem)result.Subscriptions!.Single().MonitoredItems.Single(); + Assert.That( + actual.FilteredRetainConditionIds.Memory.ToArray(), + Is.EqualTo(s_filteredRetainConditionIds)); + } + + [Test] + public async Task StoreAndRestoreRoundTripsAbsentFilteredRetainConditionIdsAsync() + { + using var kv = new InMemorySharedKeyValueStore(); + SharedKeyValueSubscriptionStore active = CreateStore(kv); + SharedKeyValueSubscriptionStore backup = CreateStore(kv); + + await active.StoreSubscriptionsAsync([NewSubscription(104, 14)]).ConfigureAwait(false); + RestoreSubscriptionResult result = await backup.RestoreSubscriptionsAsync().ConfigureAwait(false); + + Assert.That(result.Success, Is.True); + var actual = (StoredMonitoredItem)result.Subscriptions!.Single().MonitoredItems.Single(); + Assert.That(actual.FilteredRetainConditionIds.IsEmpty, Is.True); + } + + [Test] + public void CloneMonitoredItemPreservesFilteredRetainConditionIds() + { + StoredMonitoredItem item = NewItem(105, 15); + item.FilteredRetainConditionIds = [.. s_filteredRetainConditionIds]; + MethodInfo clone = GetPrivateMethod( + "CloneMonitoredItem", + typeof(IStoredMonitoredItem)); + + var cloned = (StoredMonitoredItem)clone.Invoke(null, [item])!; + + Assert.That( + cloned.FilteredRetainConditionIds.Memory.ToArray(), + Is.EqualTo(s_filteredRetainConditionIds)); + } + [Test] public void CloneMonitoredItemPreservesLifecycleState() { diff --git a/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs b/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs index 8aae44af17..9d27718a05 100644 --- a/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs +++ b/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs @@ -5188,6 +5188,8 @@ namespace Opc.Ua.Server.Tests public NumericRange ParsedIndexRange { get; set; } + public ArrayOf FilteredRetainConditionIds { get; set; } + public bool IsDurable { get; set; } public ServiceResult LastError { get; set; } diff --git a/tests/Opc.Ua.Server.Tests/FilterRetainTests.cs b/tests/Opc.Ua.Server.Tests/FilterRetainTests.cs index b6bb87c1d7..03b9b8b9f9 100644 --- a/tests/Opc.Ua.Server.Tests/FilterRetainTests.cs +++ b/tests/Opc.Ua.Server.Tests/FilterRetainTests.cs @@ -7,7 +7,7 @@ #pragma warning disable CA2000 using System.Collections.Generic; using System.Diagnostics; -using System.Reflection; +using System.Linq; using BenchmarkDotNet.Attributes; using Moq; using NUnit.Framework; @@ -28,6 +28,14 @@ namespace Opc.Ua.Server.Tests { private SystemContext m_systemContext; private IFilterContext m_filterContext; + private MonitoredItemQueueFactory m_queueFactory; + + [OneTimeTearDown] + public void OneTimeTearDown() + { + m_queueFactory?.Dispose(); + m_queueFactory = null; + } internal static readonly LocalizedText InService = new("en", "In Service"); internal static readonly LocalizedText OutOfService = new("en", "Out of Service"); @@ -55,7 +63,7 @@ namespace Opc.Ua.Server.Tests alarm.SetLimitState(systemContext, desiredState); EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); CanSendFilteredAlarm(monitoredItem, GetFilterContext(telemetry), filter, alarm, pass, telemetry); } @@ -81,7 +89,7 @@ namespace Opc.Ua.Server.Tests EventFilter filter = GetHighOnlyEventFilter(addClauses: !pass, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); CanSendFilteredAlarm(monitoredItem, context, filter, alarm, pass, telemetry); } @@ -97,7 +105,7 @@ namespace Opc.Ua.Server.Tests IFilterContext context = GetFilterContext(telemetry); EventFilter filter = GetHighOnlyEventFilter(addClauses: !pass, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); CanSendFilteredAlarm(monitoredItem, context, filter, certificateType, pass, telemetry); } @@ -116,7 +124,7 @@ namespace Opc.Ua.Server.Tests alarm.SetLimitState(GetSystemContext(telemetry), LimitAlarmStates.Inactive); EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); CanSendFilteredAlarm(monitoredItem, GetFilterContext(telemetry), filter, alarm, expected: false, telemetry); } @@ -136,7 +144,7 @@ namespace Opc.Ua.Server.Tests IFilterContext filterContext = GetFilterContext(telemetry); EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); SystemContext systemContext = GetSystemContext(telemetry); alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); @@ -175,7 +183,7 @@ namespace Opc.Ua.Server.Tests telemetry: telemetry); EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); SystemContext systemContext = GetSystemContext(telemetry); IFilterContext filterContext = GetFilterContext(telemetry); @@ -211,7 +219,7 @@ namespace Opc.Ua.Server.Tests telemetry: telemetry); EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); SystemContext systemContext = GetSystemContext(telemetry); IFilterContext filterContext = GetFilterContext(telemetry); @@ -279,7 +287,7 @@ namespace Opc.Ua.Server.Tests }; _ = filter.Validate(filterContext); - using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); // 16 States in Table B.3 @@ -384,8 +392,335 @@ namespace Opc.Ua.Server.Tests CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected, telemetry); } + /// + /// ConditionRefresh puts the ConditionState itself into the event list rather than + /// an InstanceStateSnapshot, so filtered retain has to recognise that shape too. + /// + [Test] + public void ConditionStateTargetParticipatesInFilteredRetain() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + SystemContext systemContext = GetSystemContext(telemetry); + IFilterContext filterContext = GetFilterContext(telemetry); + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + + alarm.SetLimitState(systemContext, LimitAlarmStates.High); + alarm.Retain.Value = true; + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm), + Is.True, + "the alarm passes the where clause"); + + alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); + alarm.Retain.Value = false; + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm), + Is.True, + "leaving filter scope produces the trailing event"); + + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm), + Is.False, + "the trailing event is sent once"); + } + + /// + /// A branch shares its parent's NodeId, so tracking by NodeId alone would let one of + /// them consume the entry the other relies on. + /// + [Test] + public void BranchesDoNotShareFilteredRetainState() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + SystemContext systemContext = GetSystemContext(telemetry); + IFilterContext filterContext = GetFilterContext(telemetry); + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + + alarm.SetLimitState(systemContext, LimitAlarmStates.High); + alarm.Retain.Value = true; + + var branch = (ExclusiveLevelAlarmState)alarm.CreateBranch(systemContext, new NodeId(1, 1)); + Assert.That(branch, Is.Not.Null); + Assert.That(branch.NodeId, Is.EqualTo(alarm.NodeId), "a branch keeps the parent NodeId"); + + // both are in scope; each has to claim its own entry. + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm), + Is.True); + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch), + Is.True); + + // the branch leaves scope and consumes its own entry only. + branch.SetLimitState(systemContext, LimitAlarmStates.Inactive); + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch), + Is.True, + "the branch gets its trailing event"); + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch), + Is.False); + + // the parent is untouched by that and still gets its own trailing event. + alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm), + Is.True, + "the parent entry survived the branch transition"); + } + + /// + /// SupportsFilteredRetain is not an instance child, so nothing that copies a + /// condition by walking its children carries it. CreateBranch copies it explicitly. + /// + [Test] + [TestCase(false)] + [TestCase(true)] + public void BranchInheritsSupportsFilteredRetain(bool supportsFilteredRetain) + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: supportsFilteredRetain, + telemetry: telemetry); + + var branch = (ConditionState)alarm.CreateBranch( + GetSystemContext(telemetry), + new NodeId(1, 1)); + + Assert.That(branch, Is.Not.Null); + Assert.That(branch.SupportsFilteredRetain, Is.Not.Null); + Assert.That(branch.SupportsFilteredRetain.Value, Is.EqualTo(supportsFilteredRetain)); + Assert.That( + branch.SupportsFilteredRetain, + Is.Not.SameAs(alarm.SupportsFilteredRetain), + "the branch owns its copy"); + } + + /// + /// A condition that never opted in must not get a property out of thin air. + /// + [Test] + public void BranchWithoutSupportsFilteredRetainStaysUnset() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: false, + filterRetainValue: false, + telemetry: telemetry); + + var branch = (ConditionState)alarm.CreateBranch( + GetSystemContext(telemetry), + new NodeId(1, 1)); + + Assert.That(branch, Is.Not.Null); + Assert.That(branch.SupportsFilteredRetain, Is.Null); + } + + /// + /// Part 9 provides SupportsFilteredRetain on the ConditionType only, and the standard + /// nodeset declares no modelling rule for it, so condition instances must not expose + /// it as an address space child. + /// + [Test] + public void SupportsFilteredRetainIsNotAnInstanceChild() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + SystemContext systemContext = GetSystemContext(telemetry); + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + var children = new List(); + alarm.GetChildren(systemContext, children); + + Assert.That( + children.Any(child => + child.BrowseName.Name == BrowseNames.SupportsFilteredRetain), + Is.False); + Assert.That( + alarm.FindChild( + systemContext, + QualifiedName.From(BrowseNames.SupportsFilteredRetain)), + Is.Null); + } + + /// + /// Every entry means "this condition passed the previous where clause", so keeping + /// them across a where clause change would produce a trailing event against a filter + /// that never saw the condition pass. + /// + [Test] + public void ModifyAttributesDiscardsFilteredRetainWhenWhereClauseChanges() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + SystemContext systemContext = GetSystemContext(telemetry); + IFilterContext filterContext = GetFilterContext(telemetry); + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + + alarm.SetLimitState(systemContext, LimitAlarmStates.High); + alarm.Retain.Value = true; + CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry); + + // a where clause the condition has never been evaluated against. + var replacement = new EventFilter + { + SelectClauses = GetSelectFields(), + WhereClause = GetStateFilter() + }; + _ = replacement.Validate(filterContext); + + monitoredItem.ModifyAttributes( + DiagnosticsMasks.All, + TimestampsToReturn.Server, + 3, + replacement, + replacement, + null, + 1000.0, + 10, + false); + + alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); + alarm.Retain.Value = false; + CanSendFilteredAlarm( + monitoredItem, + filterContext, + replacement, + alarm, + expected: false, + telemetry); + } + + /// + /// A modification that leaves the where clause alone - a new queue size, say - has to + /// keep the bookkeeping, otherwise the trailing event is lost. + /// + [Test] + public void ModifyAttributesKeepsFilteredRetainWhenWhereClauseIsUnchanged() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + SystemContext systemContext = GetSystemContext(telemetry); + IFilterContext filterContext = GetFilterContext(telemetry); + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + + alarm.SetLimitState(systemContext, LimitAlarmStates.High); + alarm.Retain.Value = true; + CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry); + + EventFilter sameFilter = GetHighOnlyEventFilter(addClauses: true, telemetry); + + monitoredItem.ModifyAttributes( + DiagnosticsMasks.All, + TimestampsToReturn.Server, + 3, + sameFilter, + sameFilter, + null, + 1000.0, + 20, + false); + + alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); + alarm.Retain.Value = false; + CanSendFilteredAlarm( + monitoredItem, + filterContext, + sameFilter, + alarm, + expected: true, + telemetry); + } + + /// + /// A durable subscription has to carry the bookkeeping across a restart, otherwise + /// the first transition out of filter scope after the restart is dropped. + /// + [Test] + public void FilteredRetainSurvivesADurableRoundTrip() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm( + addFilterRetain: true, + filterRetainValue: true, + telemetry: telemetry); + + SystemContext systemContext = GetSystemContext(telemetry); + IFilterContext filterContext = GetFilterContext(telemetry); + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + + IStoredMonitoredItem stored; + using (TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry)) + { + alarm.SetLimitState(systemContext, LimitAlarmStates.High); + alarm.Retain.Value = true; + CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry); + + stored = monitoredItem.ToStorableMonitoredItem(); + } + + Assert.That(stored.FilteredRetainConditionIds.IsNull, Is.False); + Assert.That(stored.FilteredRetainConditionIds.Count, Is.EqualTo(1)); + + using TestableMonitoredItem restored = RestoreMonitoredItem(stored, telemetry); + + alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive); + alarm.Retain.Value = false; + CanSendFilteredAlarm(restored, filterContext, filter, alarm, expected: true, telemetry); + CanSendFilteredAlarm(restored, filterContext, filter, alarm, expected: false, telemetry); + } + + /// + /// An item with nothing to track stores nothing. + /// + [Test] + public void NothingIsStoredWhenNoConditionIsTracked() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry); + using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry); + + Assert.That( + monitoredItem.ToStorableMonitoredItem().FilteredRetainConditionIds.IsNull, + Is.True); + } + private void CanSendFilteredAlarm( - MonitoredItem monitoredItem, + TestableMonitoredItem monitoredItem, IFilterContext context, EventFilter filter, BaseObjectState alarm, @@ -397,14 +732,11 @@ namespace Opc.Ua.Server.Tests var eventSnapshot = new InstanceStateSnapshot(); eventSnapshot.Initialize(systemContext, alarm); - const BindingFlags eFlags = BindingFlags.Instance | BindingFlags.NonPublic; - MethodInfo methodInfo = typeof(MonitoredItem).GetMethod("CanSendFilteredAlarm", eFlags); Debug.WriteLine("Expecting " + expected.ToString()); - object result = methodInfo.Invoke(monitoredItem, [context, filter, eventSnapshot]); - Assert.That(result, Is.Not.Null); - Assert.That(result.GetType().Name, Is.EqualTo("Boolean")); - Assert.That((bool)result, Is.EqualTo(expected)); + Assert.That( + monitoredItem.CanSendFilteredAlarmForTest(context, filter, eventSnapshot), + Is.EqualTo(expected)); } private ExclusiveLevelAlarmState GetExclusiveLevelAlarm( @@ -640,23 +972,13 @@ namespace Opc.Ua.Server.Tests return m_filterContext; } - private MonitoredItem CreateMonitoredItem(MonitoringFilter filter, ITelemetryContext telemetry) + private TestableMonitoredItem CreateMonitoredItem( + MonitoringFilter filter, + ITelemetryContext telemetry) { - var serverMock = new Mock(); - - SystemContext systemContext = GetSystemContext(telemetry); - serverMock.Setup(s => s.Telemetry).Returns(telemetry); - serverMock.Setup(s => s.NamespaceUris).Returns(systemContext.NamespaceUris); - serverMock.Setup(s => s.TypeTree).Returns((TypeTable)systemContext.TypeTable); - using var queueFactory = new MonitoredItemQueueFactory(telemetry); - serverMock.Setup(s => s.MonitoredItemQueueFactory) - .Returns(queueFactory); - - var nodeMangerMock = new Mock(); - - return new MonitoredItem( - serverMock.Object, - nodeMangerMock.Object, + return new TestableMonitoredItem( + CreateServer(telemetry), + new Mock().Object, null, 1, 2, @@ -673,5 +995,97 @@ namespace Opc.Ua.Server.Tests false, 1000); } + + private TestableMonitoredItem RestoreMonitoredItem( + IStoredMonitoredItem storedMonitoredItem, + ITelemetryContext telemetry) + { + return new TestableMonitoredItem( + CreateServer(telemetry), + new Mock().Object, + null, + storedMonitoredItem); + } + + private IServerInternal CreateServer(ITelemetryContext telemetry) + { + var serverMock = new Mock(); + + SystemContext systemContext = GetSystemContext(telemetry); + serverMock.Setup(s => s.Telemetry).Returns(telemetry); + serverMock.Setup(s => s.NamespaceUris).Returns(systemContext.NamespaceUris); + serverMock.Setup(s => s.TypeTree).Returns((TypeTable)systemContext.TypeTable); + + // the factory has to outlive every item the fixture builds, so it is owned by + // the fixture rather than by the call that hands it to a monitored item. + m_queueFactory ??= new MonitoredItemQueueFactory(telemetry); + serverMock.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory); + + return serverMock.Object; + } + + /// + /// Exposes the protected filtered retain evaluation so the suite does not have to + /// reach for it by reflection, where a signature change would compile cleanly and + /// only fail at run time. + /// + private sealed class TestableMonitoredItem : MonitoredItem + { + public TestableMonitoredItem( + IServerInternal server, + IAsyncNodeManager nodeManager, + object managerHandle, + uint subscriptionId, + uint id, + ReadValueId itemToMonitor, + DiagnosticsMasks diagnosticsMasks, + TimestampsToReturn timestampsToReturn, + MonitoringMode monitoringMode, + uint clientHandle, + MonitoringFilter originalFilter, + MonitoringFilter filterToUse, + Range range, + double samplingInterval, + uint queueSize, + bool discardOldest, + double sourceSamplingInterval) + : base( + server, + nodeManager, + managerHandle, + subscriptionId, + id, + itemToMonitor, + diagnosticsMasks, + timestampsToReturn, + monitoringMode, + clientHandle, + originalFilter, + filterToUse, + range, + samplingInterval, + queueSize, + discardOldest, + sourceSamplingInterval) + { + } + + public TestableMonitoredItem( + IServerInternal server, + IAsyncNodeManager nodeManager, + object managerHandle, + IStoredMonitoredItem storedMonitoredItem) + : base(server, nodeManager, managerHandle, storedMonitoredItem) + { + } + + public bool CanSendFilteredAlarmForTest( + IFilterContext context, + EventFilter filter, + IFilterTarget instance) + { + return CanSendFilteredAlarm(context, filter, instance); + } + } } }