From 2e7697c582d04b67fb4081b59ff73ec93a149117 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 06:01:27 +0000 Subject: [PATCH 1/4] Initial plan From 17d89ef77929974807f8dc68f3364c8402bad4cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 06:12:44 +0000 Subject: [PATCH 2/4] Add triggering item tracking and restoration support Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- .../Subscription/MonitoredItem.cs | 18 +- .../Subscription/MonitoredItemState.cs | 16 ++ .../Subscription/Subscription.cs | 201 ++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs b/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs index bd08449aa7..b611899d1f 100644 --- a/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs +++ b/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs @@ -136,6 +136,8 @@ public virtual void Restore(MonitoredItemState state) State = state; ClientHandle = state.ClientId; ServerId = state.ServerId; + TriggeringItemId = state.TriggeringItemId; + TriggeredItems = state.TriggeredItems != null ? new UInt32Collection(state.TriggeredItems) : null; } /// @@ -144,7 +146,9 @@ public virtual void Snapshot(out MonitoredItemState state) state = new MonitoredItemState(State) { ServerId = Status.Id, - ClientId = ClientHandle + ClientId = ClientHandle, + TriggeringItemId = TriggeringItemId, + TriggeredItems = TriggeredItems != null ? new UInt32Collection(TriggeredItems) : null }; } @@ -1070,6 +1074,18 @@ private static EventFilter GetDefaultEventFilter() private MonitoredItemEventCache? m_eventCache; private IEncodeable? m_lastNotification; private event MonitoredItemNotificationEventHandler? m_Notification; + + /// + /// Server-side identifier of the triggering item if this monitored item + /// is triggered by another item. 0 indicates this item is not triggered. + /// + internal uint TriggeringItemId { get; set; } + + /// + /// Collection of server-side identifiers of monitored items that are + /// triggered by this item. Null if this item does not trigger any other items. + /// + internal UInt32Collection? TriggeredItems { get; set; } } /// diff --git a/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs b/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs index 181ca0bd14..eb6cd6db06 100644 --- a/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs +++ b/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs @@ -79,6 +79,22 @@ public MonitoredItemState(MonitoredItemOptions options) /// [DataMember(Order = 15)] public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Server-side identifier of the triggering item if this monitored item + /// is triggered by another item. 0 indicates this item is not triggered. + /// Used to restore triggering links after session reconnect. + /// + [DataMember(Order = 16)] + public uint TriggeringItemId { get; init; } + + /// + /// Collection of server-side identifiers of monitored items that are + /// triggered by this item. Empty or null if this item does not trigger + /// any other items. Used to restore triggering links after session reconnect. + /// + [DataMember(Order = 17)] + public UInt32Collection? TriggeredItems { get; init; } } /// diff --git a/Libraries/Opc.Ua.Client/Subscription/Subscription.cs b/Libraries/Opc.Ua.Client/Subscription/Subscription.cs index 9c2617cf5e..7937565130 100644 --- a/Libraries/Opc.Ua.Client/Subscription/Subscription.cs +++ b/Libraries/Opc.Ua.Client/Subscription/Subscription.cs @@ -1037,6 +1037,9 @@ public async Task> CreateItemsAsync(CancellationToken ct = m_changeMask |= SubscriptionChangeMask.ItemsCreated; ChangesCompleted(); + // Restore triggering relationships after items are created + await RestoreTriggeringAsync(ct).ConfigureAwait(false); + // return the list of items affected by the change. return itemsToCreate; } @@ -1136,6 +1139,82 @@ public async Task> DeleteItemsAsync( return itemsToDelete; } + /// + /// Restores triggering relationships for monitored items that were + /// configured with triggers before reconnection. + /// + private async Task RestoreTriggeringAsync(CancellationToken ct = default) + { + VerifySessionAndSubscriptionState(true); + + // Build triggering groups outside of lock to avoid await in lock + Dictionary> triggeringGroups; + lock (m_cache) + { + // Group monitored items by their triggering item + triggeringGroups = new Dictionary>(); + foreach (MonitoredItem item in m_monitoredItems.Values) + { + if (item.TriggeredItems != null && item.TriggeredItems.Count > 0) + { + // This item triggers other items + var triggeredServerIds = new List(); + foreach (uint triggeredClientHandle in item.TriggeredItems) + { + // Find the monitored item by client handle + if (m_monitoredItems.TryGetValue(triggeredClientHandle, out MonitoredItem? triggeredItem) && + triggeredItem.Status.Created) + { + triggeredServerIds.Add(triggeredItem.Status.Id); + } + } + + if (triggeredServerIds.Count > 0) + { + if (!triggeringGroups.TryGetValue(item.Status.Id, out List? list)) + { + list = []; + triggeringGroups[item.Status.Id] = list; + } + list.AddRange(triggeredServerIds); + } + } + } + } + + // Call SetTriggering for each triggering item + foreach (var kvp in triggeringGroups) + { + uint triggeringItemId = kvp.Key; + var linksToAdd = new UInt32Collection(kvp.Value); + + try + { + await Session.SetTriggeringAsync( + null, + Id, + triggeringItemId, + linksToAdd, + null, + ct).ConfigureAwait(false); + + m_logger.LogInformation( + "Restored {Count} triggering links for MonitoredItem {TriggeringItemId} in Subscription {SubscriptionId}", + linksToAdd.Count, + triggeringItemId, + Id); + } + catch (Exception ex) + { + m_logger.LogError( + ex, + "Failed to restore triggering links for MonitoredItem {TriggeringItemId} in Subscription {SubscriptionId}", + triggeringItemId, + Id); + } + } + } + /// /// Set monitoring mode of items. /// @@ -1197,6 +1276,125 @@ public async Task> DeleteItemsAsync( return errors; } + /// + /// Sets the triggering relationships for a monitored item in this subscription + /// and tracks them for automatic restoration after reconnection. + /// + /// The monitored item that will trigger other items. + /// Monitored items to be reported when the triggering item changes. + /// Monitored items to stop reporting when the triggering item changes. + /// Cancellation token. + /// The response from the server. + /// Thrown when triggeringItem is null. + /// Thrown when the operation fails. + public async Task SetTriggeringAsync( + MonitoredItem triggeringItem, + IList? linksToAdd, + IList? linksToRemove, + CancellationToken ct = default) + { + if (triggeringItem == null) + { + throw new ArgumentNullException(nameof(triggeringItem)); + } + + using Activity? activity = m_telemetry.StartActivity(); + VerifySessionAndSubscriptionState(true); + + if (!triggeringItem.Status.Created) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, + "Triggering item has not been created on the server."); + } + + // Convert monitored items to server IDs + var serverIdsToAdd = new UInt32Collection(); + var clientHandlesToAdd = new UInt32Collection(); + if (linksToAdd != null) + { + foreach (MonitoredItem item in linksToAdd) + { + if (!item.Status.Created) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, + $"Monitored item '{item.DisplayName}' has not been created on the server."); + } + serverIdsToAdd.Add(item.Status.Id); + clientHandlesToAdd.Add(item.ClientHandle); + } + } + + var serverIdsToRemove = new UInt32Collection(); + var clientHandlesToRemove = new UInt32Collection(); + if (linksToRemove != null) + { + foreach (MonitoredItem item in linksToRemove) + { + if (!item.Status.Created) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, + $"Monitored item '{item.DisplayName}' has not been created on the server."); + } + serverIdsToRemove.Add(item.Status.Id); + clientHandlesToRemove.Add(item.ClientHandle); + } + } + + // Call the Session SetTriggering method + SetTriggeringResponse response = await Session.SetTriggeringAsync( + null, + Id, + triggeringItem.Status.Id, + serverIdsToAdd, + serverIdsToRemove, + ct).ConfigureAwait(false); + + // Update the triggering relationships for automatic restoration + lock (m_cache) + { + // Initialize the triggered items collection if needed + triggeringItem.TriggeredItems ??= new UInt32Collection(); + + // Add new links + if (clientHandlesToAdd.Count > 0) + { + foreach (uint clientHandle in clientHandlesToAdd) + { + if (!triggeringItem.TriggeredItems.Contains(clientHandle)) + { + triggeringItem.TriggeredItems.Add(clientHandle); + } + + // Update the triggered item to remember its triggering item + if (m_monitoredItems.TryGetValue(clientHandle, out MonitoredItem? triggeredItem)) + { + triggeredItem.TriggeringItemId = triggeringItem.Status.Id; + } + } + } + + // Remove links + if (clientHandlesToRemove.Count > 0) + { + foreach (uint clientHandle in clientHandlesToRemove) + { + triggeringItem.TriggeredItems.Remove(clientHandle); + + // Clear the triggering item reference + if (m_monitoredItems.TryGetValue(clientHandle, out MonitoredItem? triggeredItem)) + { + triggeredItem.TriggeringItemId = 0; + } + } + } + } + + return response; + } + /// /// Tells the server to refresh all conditions being monitored by the subscription. /// @@ -1349,6 +1547,9 @@ await session.RemoveSubscriptionsAsync(subscriptionsToRemove, ct) m_changeMask |= SubscriptionChangeMask.Transferred; ChangesCompleted(); + // Restore triggering relationships after subscription transfer + await RestoreTriggeringAsync(ct).ConfigureAwait(false); + StartKeepAliveTimer(); TraceState("TRANSFERRED ASYNC"); From 1ca89d7a1fe46e98023c8a423a1cd5c6e830550b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 06:35:40 +0000 Subject: [PATCH 3/4] Changes before error encountered Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs b/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs index 208b718f15..61c31ef819 100644 --- a/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs +++ b/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs @@ -1462,5 +1462,99 @@ private void DeferSubscriptionAcknowledge( e.DeferredAcknowledgementsToSend.Clear(); e.AcknowledgementsToSend.Clear(); } + + [Test] + [Order(900)] + public async Task SetTriggeringTrackingAsync() + { + // Create a subscription + var subscription = new Subscription(Session.Telemetry) + { + PublishingEnabled = true, + PublishingInterval = 1000, + KeepAliveCount = 10, + LifetimeCount = 100, + MaxNotificationsPerPublish = 1000, + Priority = 100 + }; + + Session.AddSubscription(subscription); + await subscription.CreateAsync(CancellationToken.None).ConfigureAwait(false); + Assert.That(subscription.Created, Is.True); + + // Create monitored items + var triggeringItem = new MonitoredItem(Session.Telemetry) + { + StartNodeId = VariableIds.Server_ServerStatus_CurrentTime, + AttributeId = Attributes.Value, + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = 0, + QueueSize = 0, + DiscardOldest = true + }; + + var triggeredItem1 = new MonitoredItem(Session.Telemetry) + { + StartNodeId = VariableIds.Server_ServerStatus_State, + AttributeId = Attributes.Value, + MonitoringMode = MonitoringMode.Sampling, + SamplingInterval = 0, + QueueSize = 0, + DiscardOldest = true + }; + + var triggeredItem2 = new MonitoredItem(Session.Telemetry) + { + StartNodeId = VariableIds.Server_ServerStatus_BuildInfo, + AttributeId = Attributes.Value, + MonitoringMode = MonitoringMode.Sampling, + SamplingInterval = 0, + QueueSize = 0, + DiscardOldest = true + }; + + subscription.AddItem(triggeringItem); + subscription.AddItem(triggeredItem1); + subscription.AddItem(triggeredItem2); + + // Create the items + await subscription.ApplyChangesAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(triggeringItem.Created, Is.True); + Assert.That(triggeredItem1.Created, Is.True); + Assert.That(triggeredItem2.Created, Is.True); + + // Set up triggering relationship using the new method + var linksToAdd = new List { triggeredItem1, triggeredItem2 }; + SetTriggeringResponse response = await subscription.SetTriggeringAsync( + triggeringItem, + linksToAdd, + null, + CancellationToken.None).ConfigureAwait(false); + + Assert.That(response, Is.Not.Null); + + // Verify the triggering relationships are tracked + Assert.That(triggeringItem.TriggeredItems, Is.Not.Null); + Assert.That(triggeringItem.TriggeredItems.Count, Is.EqualTo(2)); + Assert.That(triggeringItem.TriggeredItems, Does.Contain(triggeredItem1.ClientHandle)); + Assert.That(triggeringItem.TriggeredItems, Does.Contain(triggeredItem2.ClientHandle)); + + Assert.That(triggeredItem1.TriggeringItemId, Is.EqualTo(triggeringItem.Status.Id)); + Assert.That(triggeredItem2.TriggeringItemId, Is.EqualTo(triggeringItem.Status.Id)); + + // Snapshot the subscription state + subscription.Snapshot(out SubscriptionState state); + + // Verify that the triggering relationships are persisted + MonitoredItemState? triggeringItemState = state.MonitoredItems + .FirstOrDefault(m => m.ClientId == triggeringItem.ClientHandle); + Assert.That(triggeringItemState, Is.Not.Null); + Assert.That(triggeringItemState.TriggeredItems, Is.Not.Null); + Assert.That(triggeringItemState.TriggeredItems.Count, Is.EqualTo(2)); + + // Clean up + await subscription.DeleteAsync(true, CancellationToken.None).ConfigureAwait(false); + } } } From 894c865577cc6ee4ca48fe146fc8a20bf03e0d7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:14:29 +0000 Subject: [PATCH 4/4] Fix test to use proper subscription/monitored item creation pattern Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs b/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs index 61c31ef819..647ae7b94c 100644 --- a/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs +++ b/Tests/Opc.Ua.Client.Tests/SubscriptionTest.cs @@ -1468,7 +1468,7 @@ private void DeferSubscriptionAcknowledge( public async Task SetTriggeringTrackingAsync() { // Create a subscription - var subscription = new Subscription(Session.Telemetry) + var subscription = new Subscription(Session.DefaultSubscription) { PublishingEnabled = true, PublishingInterval = 1000, @@ -1483,7 +1483,7 @@ public async Task SetTriggeringTrackingAsync() Assert.That(subscription.Created, Is.True); // Create monitored items - var triggeringItem = new MonitoredItem(Session.Telemetry) + var triggeringItem = new MonitoredItem(subscription.DefaultItem) { StartNodeId = VariableIds.Server_ServerStatus_CurrentTime, AttributeId = Attributes.Value, @@ -1493,7 +1493,7 @@ public async Task SetTriggeringTrackingAsync() DiscardOldest = true }; - var triggeredItem1 = new MonitoredItem(Session.Telemetry) + var triggeredItem1 = new MonitoredItem(subscription.DefaultItem) { StartNodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value, @@ -1503,7 +1503,7 @@ public async Task SetTriggeringTrackingAsync() DiscardOldest = true }; - var triggeredItem2 = new MonitoredItem(Session.Telemetry) + var triggeredItem2 = new MonitoredItem(subscription.DefaultItem) { StartNodeId = VariableIds.Server_ServerStatus_BuildInfo, AttributeId = Attributes.Value, @@ -1547,7 +1547,7 @@ public async Task SetTriggeringTrackingAsync() subscription.Snapshot(out SubscriptionState state); // Verify that the triggering relationships are persisted - MonitoredItemState? triggeringItemState = state.MonitoredItems + MonitoredItemState triggeringItemState = state.MonitoredItems .FirstOrDefault(m => m.ClientId == triggeringItem.ClientHandle); Assert.That(triggeringItemState, Is.Not.Null); Assert.That(triggeringItemState.TriggeredItems, Is.Not.Null);