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