/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client.TestFramework; using Opc.Ua.Server; namespace Opc.Ua.Subscriptions.Tests { /// /// Verifies enabling disabled monitored items starts initial and periodic sampling through live services. /// [TestFixture] [Category("Subscription")] [Category("Integration")] [NonParallelizable] public sealed class SamplingModeRegressionTests : TestFixture { /// /// Registers a sampling-enabled variable and resolves its namespace in the client session. /// [OneTimeSetUp] public async Task AddSamplingManagerAsync() { await ReferenceServer.NodeManagerLifecycle.AddAsync( new SamplingManagerFactory(), callerContext: null).ConfigureAwait(false); await Session.FetchNamespaceTablesAsync().ConfigureAwait(false); int index = Session.NamespaceUris.GetIndex(kNamespaceUri); Assert.That(index, Is.GreaterThanOrEqualTo(0)); m_nodeId = new NodeId("Value", (ushort)index); } /// /// Verifies transition to Reporting produces the initial value and later updates, optionally via Sampling mode. /// [TestCase(false)] [TestCase(true)] public async Task DisabledItemStartsInitialAndPeriodicSamplingWhenEnabledAsync(bool passThroughSampling) { using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await WriteValueAsync(1.0, deadline.Token).ConfigureAwait(false); CreateSubscriptionResponse subscription = await Session.CreateSubscriptionAsync( null, 50, 100, 3, 0, true, 0, deadline.Token).ConfigureAwait(false); uint subscriptionId = subscription.SubscriptionId; try { CreateMonitoredItemsResponse created = await Session.CreateMonitoredItemsAsync( null, subscriptionId, TimestampsToReturn.Both, [ new MonitoredItemCreateRequest { ItemToMonitor = new ReadValueId { NodeId = m_nodeId, AttributeId = Attributes.Value }, MonitoringMode = MonitoringMode.Disabled, RequestedParameters = new MonitoringParameters { ClientHandle = 1, SamplingInterval = 50, QueueSize = 1, DiscardOldest = true } } ], deadline.Token).ConfigureAwait(false); Assert.That(created.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); uint itemId = created.Results[0].MonitoredItemId; if (passThroughSampling) { SetMonitoringModeResponse sampling = await Session.SetMonitoringModeAsync( null, subscriptionId, MonitoringMode.Sampling, [itemId], deadline.Token) .ConfigureAwait(false); Assert.That(sampling.Results[0], Is.EqualTo(StatusCodes.Good)); } SetMonitoringModeResponse reporting = await Session.SetMonitoringModeAsync( null, subscriptionId, MonitoringMode.Reporting, [itemId], deadline.Token) .ConfigureAwait(false); Assert.That(reporting.Results[0], Is.EqualTo(StatusCodes.Good)); DataValue initial = await NextValueAsync(deadline.Token).ConfigureAwait(false); AssertValue(initial, 1.0); await WriteValueAsync(2.0, deadline.Token).ConfigureAwait(false); DataValue next = await NextValueAsync(deadline.Token).ConfigureAwait(false); AssertValue(next, 2.0); } finally { await Session.DeleteSubscriptionsAsync(null, [subscriptionId], CancellationToken.None) .ConfigureAwait(false); } } /// /// Writes the variable through the client service and requires a successful result. /// private async ValueTask WriteValueAsync(double value, CancellationToken ct) { WriteResponse response = await Session.WriteAsync( null, [ new WriteValue { NodeId = m_nodeId, AttributeId = Attributes.Value, Value = new DataValue(value) } ], ct).ConfigureAwait(false); Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); } /// /// Reads the next data-change value within a bounded number of publishing cycles. /// private async Task NextValueAsync(CancellationToken ct) { for (int attempt = 0; attempt < 8; attempt++) { PublishResponse response = await Session.PublishAsync( new RequestHeader { TimeoutHint = 2_000 }, [], ct).ConfigureAwait(false); for (int i = 0; i < response.NotificationMessage.NotificationData.Count; i++) { if (response.NotificationMessage.NotificationData[i] .TryGetValue(out DataChangeNotification? data) && data!.MonitoredItems.Count > 0) { return data.MonitoredItems[0].Value; } } } throw new AssertionException("No sampled value arrived within eight publishing cycles."); } /// /// Requires a Good value containing the exact expected Double. /// private static void AssertValue(in DataValue value, double expected) { Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(value.WrappedValue.TryGetValue(out double actual), Is.True); Assert.That(actual, Is.EqualTo(expected)); } /// /// Identifies the address space containing the sampling regression variable. /// private const string kNamespaceUri = "urn:opcfoundation:subscription-tests:sampling-regression"; /// /// Identifies the variable whose writes are observed by the monitored item. /// private NodeId m_nodeId; /// /// Creates the sampling-enabled address space for live subscription tests. /// private sealed class SamplingManagerFactory : IAsyncNodeManagerFactory { /// public ArrayOf NamespacesUris => [kNamespaceUri]; /// public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { return new ValueTask(new SamplingManager(server, configuration)); } } /// /// Hosts a writable variable using sampling-group monitoring. /// private sealed class SamplingManager : AsyncCustomNodeManager { /// /// Enables sampling-group management for the test namespace. /// public SamplingManager(IServerInternal server, ApplicationConfiguration configuration) : base(server, configuration, true, kNamespaceUri) { } /// /// Registers the readable and writable Double with a 50 millisecond minimum sampling interval. /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, CancellationToken cancellationToken = default) { var variable = new BaseDataVariableState(null); variable.CreateAsPredefinedNode(SystemContext, cancellationToken); variable.NodeId = new NodeId("Value", NamespaceIndex); variable.BrowseName = new QualifiedName("Value", NamespaceIndex); variable.DisplayName = new LocalizedText("Value"); variable.TypeDefinitionId = VariableTypeIds.BaseDataVariableType; variable.DataType = DataTypeIds.Double; variable.ValueRank = ValueRanks.Scalar; variable.MinimumSamplingInterval = 50; variable.AccessLevel = AccessLevels.CurrentReadOrWrite; variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite; variable.Value = new Variant(1.0); await AddPredefinedNodeAsync(SystemContext, variable, cancellationToken).ConfigureAwait(false); } } } }