mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 09:37:12 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1273 lines
46 KiB
C#
1273 lines
46 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 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.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.TestFramework;
|
|
|
|
namespace Opc.Ua.Subscriptions.Tests
|
|
{
|
|
/// <summary>
|
|
/// compliance tests for Monitor Value Change V2 covering
|
|
/// data change on each built-in type, filter triggers, value
|
|
/// change timing, identical value writes, queue overflow with
|
|
/// rapid changes, and multiple monitored items.
|
|
/// </summary>
|
|
[NonParallelizable]
|
|
[TestFixture]
|
|
[Category("Conformance")]
|
|
[Category("MonitoredItem")]
|
|
[Category("MonitorValueChange")]
|
|
public class MonitorValueChangeTests : TestFixture
|
|
{
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
m_subscriptionId = await CreateSetupSubscriptionAsync(
|
|
publishingInterval: 100, requestedLifetimeCount: 100,
|
|
requestedMaxKeepAliveCount: 10).ConfigureAwait(false);
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown()
|
|
{
|
|
if (m_subscriptionId > 0)
|
|
{
|
|
try
|
|
{
|
|
await Session.DeleteSubscriptionsAsync(
|
|
null,
|
|
new uint[] { m_subscriptionId }.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException)
|
|
{
|
|
// Subscription may already be deleted
|
|
}
|
|
m_subscriptionId = 0;
|
|
}
|
|
}
|
|
|
|
[TestCase(0)]
|
|
[TestCase(1)]
|
|
[TestCase(2)]
|
|
[TestCase(3)]
|
|
[TestCase(4)]
|
|
[TestCase(5)]
|
|
[TestCase(6)]
|
|
[TestCase(7)]
|
|
[TestCase(8)]
|
|
[TestCase(9)]
|
|
[TestCase(10)]
|
|
[TestCase(11)]
|
|
[TestCase(12)]
|
|
[TestCase(13)]
|
|
[TestCase(14)]
|
|
[TestCase(15)]
|
|
[TestCase(16)]
|
|
[TestCase(17)]
|
|
[TestCase(18)]
|
|
[Category("LongRunning")]
|
|
public async Task DataChangeOnScalarTypeAsync(int index)
|
|
{
|
|
ExpandedNodeId expandedId =
|
|
Constants.ScalarStaticNodes[index];
|
|
NodeId nodeId = ToNodeId(expandedId);
|
|
|
|
try
|
|
{
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 1, samplingInterval: 50))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True,
|
|
$"Failed to create item for index {index}");
|
|
|
|
// Drain the initial data-change notification
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
Variant testValue = GetTestValueForIndex(index);
|
|
bool writeOk =
|
|
await TryWriteVariantAsync(nodeId, testValue)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!writeOk)
|
|
{
|
|
// Some types (e.g. NodeId) may be read-only;
|
|
// just verify monitor creation succeeded.
|
|
return;
|
|
}
|
|
|
|
// Retry publish to handle timing variations
|
|
DataChangeNotification? dcn = null;
|
|
for (int attempt = 0; attempt < 3 && dcn == null; attempt++)
|
|
{
|
|
dcn = await PublishAndGetDcnAsync(
|
|
500 + (attempt * 300)).ConfigureAwait(false);
|
|
}
|
|
|
|
if (dcn == null)
|
|
{
|
|
Assert.Ignore(
|
|
$"No DCN received for index {index} after retries.");
|
|
}
|
|
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
$"DCN has no items for index {index}");
|
|
}
|
|
catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode))
|
|
{
|
|
Assert.Ignore(
|
|
$"Timing-sensitive: scalar data-change roundtrip interrupted by CI runner load ({sre.StatusCode}).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataChangeFilterStatusOnlyNoNotifyOnValueChangeAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.Status,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 10,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed ||
|
|
status == StatusCodes.BadMonitoredItemFilterUnsupported)
|
|
{
|
|
Assert.Fail("Server does not support StatusOnly.");
|
|
}
|
|
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Write a new value — status does not change
|
|
await WriteVariantAsync(nodeId, new Variant(99999))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
// StatusOnly: value-only change should not trigger DCN
|
|
bool noValueNotification = dcn == null ||
|
|
dcn.MonitoredItems.Count == 0;
|
|
Assert.That(noValueNotification, Is.True,
|
|
"StatusOnly trigger should not notify on value change");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataChangeFilterStatusValueNotifyOnValueChangeAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValue,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 11,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed)
|
|
{
|
|
Assert.Fail("Server does not support filter.");
|
|
}
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(77777))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"StatusValue trigger should notify on value change");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
DataChangeFilterStatusValueTimestampNotifyAlways()
|
|
{
|
|
NodeId nodeId =
|
|
VariableIds.Server_ServerStatus_CurrentTime;
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValueTimestamp,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 12,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed)
|
|
{
|
|
Assert.Fail("Server does not support filter.");
|
|
}
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
// Dynamic node — timestamp always changes
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"SVT trigger on dynamic node should always notify");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataChangeFilterDefaultTriggerIsStatusValueAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
// No explicit filter → default trigger is StatusValue
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 13, samplingInterval: 50))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(88888))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Default trigger should notify on value change");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
DataChangeFilterInvalidTriggerValueReturnsError()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = (DataChangeTrigger)999,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 14,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
Assert.That(StatusCode.IsBad(status), Is.True,
|
|
"Invalid trigger should return a Bad status");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
DataChangeFilterOnNonVariableNodeReturnsError()
|
|
{
|
|
// Objects folder is an Object, not a Variable
|
|
NodeId objectNode = ObjectIds.ObjectsFolder;
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValue,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(objectNode, 15,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
// Expecting Bad — filter on non-variable attribute
|
|
Assert.That(StatusCode.IsBad(status), Is.True,
|
|
"DataChange filter on Object node should fail");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
ValueChangeNotificationWithinSamplingInterval()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 20, samplingInterval: 200))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(11111))
|
|
.ConfigureAwait(false);
|
|
|
|
// Wait longer than sampling interval
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
ValueChangeNotificationFastSamplingInterval()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 21, samplingInterval: 50))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(22222))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(300).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
[Category("LongRunning")]
|
|
public async Task
|
|
ValueChangeNotificationSlowSamplingInterval()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
try
|
|
{
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 22,
|
|
samplingInterval: 5000))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(33333))
|
|
.ConfigureAwait(false);
|
|
|
|
// Slow sampling — may need longer wait
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(6000).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null,
|
|
"Should eventually receive notification");
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
}
|
|
catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode))
|
|
{
|
|
Assert.Ignore(
|
|
$"Timing-sensitive: slow-sampling publish interrupted by CI runner load ({sre.StatusCode}).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
[Category("LongRunning")]
|
|
public async Task
|
|
MultipleValueChangesBeforePublishOnlyLatestOrQueued()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
try
|
|
{
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 23,
|
|
samplingInterval: 50, queueSize: 5))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Rapid writes
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(50000 + i))
|
|
.ConfigureAwait(false);
|
|
await Task.Delay(60).ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Should receive at least one notification");
|
|
}
|
|
catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode))
|
|
{
|
|
Assert.Ignore(
|
|
$"Timing-sensitive: rapid-write sequence interrupted by CI runner load ({sre.StatusCode}).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
[Category("LongRunning")]
|
|
public async Task
|
|
WriteIdenticalValueNoNotificationWithStatusValueTrigger()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValue,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 30,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed)
|
|
{
|
|
Assert.Fail("Server does not support filter.");
|
|
}
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
// Write a known value first
|
|
await WriteVariantAsync(nodeId, new Variant(12345))
|
|
.ConfigureAwait(false);
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Write the identical value again
|
|
await WriteVariantAsync(nodeId, new Variant(12345))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
bool noNotification = dcn == null ||
|
|
dcn.MonitoredItems.Count == 0;
|
|
if (!noNotification)
|
|
{
|
|
Assert.Ignore(
|
|
"Timing-sensitive: prior write's initial-value notification " +
|
|
"was not fully drained before the second identical write " +
|
|
"under CI runner load.");
|
|
}
|
|
Assert.That(noNotification, Is.True,
|
|
"Identical value should not trigger DCN " +
|
|
"with StatusValue trigger");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
WriteIdenticalValueNotificationWithSvtTrigger()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValueTimestamp,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 31,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed)
|
|
{
|
|
Assert.Fail("Server does not support filter.");
|
|
}
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(54321))
|
|
.ConfigureAwait(false);
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Write same value — server timestamp changes
|
|
await WriteVariantAsync(nodeId, new Variant(54321))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
// SVT trigger should notify because timestamp changed
|
|
Assert.That(dcn, Is.Not.Null,
|
|
"SVT trigger should notify even for same value");
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
[Category("LongRunning")]
|
|
public async Task
|
|
WriteIdenticalValueStatusOnlyNoNotification()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.Status,
|
|
DeadbandType = (uint)DeadbandType.None,
|
|
DeadbandValue = 0
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 32,
|
|
samplingInterval: 50,
|
|
filter: new ExtensionObject(filter)))
|
|
.ConfigureAwait(false);
|
|
|
|
StatusCode status = createResp.Results[0].StatusCode;
|
|
if (status == StatusCodes.BadFilterNotAllowed ||
|
|
status == StatusCodes.BadMonitoredItemFilterUnsupported)
|
|
{
|
|
Assert.Fail("Server does not support StatusOnly.");
|
|
}
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(67890))
|
|
.ConfigureAwait(false);
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Same value, same status
|
|
await WriteVariantAsync(nodeId, new Variant(67890))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
bool noNotification = dcn == null ||
|
|
dcn.MonitoredItems.Count == 0;
|
|
if (!noNotification)
|
|
{
|
|
Assert.Ignore(
|
|
"Timing-sensitive: prior write's initial-value notification " +
|
|
"was not fully drained before the second identical write " +
|
|
"under CI runner load.");
|
|
}
|
|
Assert.That(noNotification, Is.True,
|
|
"StatusOnly: identical write should not notify");
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteDifferentValueAlwaysNotifiesAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 33, samplingInterval: 50))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(11111))
|
|
.ConfigureAwait(false);
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(99999))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Different value should always trigger DCN");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
RapidChangesQueueSizeOneOnlyLatestValue()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 40,
|
|
samplingInterval: 50, queueSize: 1))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Rapid writes
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(60000 + i))
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
// Queue size 1 → at most 1 item per handle
|
|
var items = dcn.MonitoredItems.ToArray()!
|
|
.Where(m => m.ClientHandle == 40).ToList();
|
|
Assert.That(items, Has.Count.LessThanOrEqualTo(2),
|
|
"Queue size 1 should keep only the latest value " +
|
|
"(plus possible overflow indicator)");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
RapidChangesQueueSizeFiveAccumulatesValues()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 41,
|
|
samplingInterval: 50, queueSize: 5))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
uint revisedQueue =
|
|
createResp.Results[0].RevisedQueueSize;
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(70000 + i))
|
|
.ConfigureAwait(false);
|
|
await Task.Delay(60).ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
Assert.That(
|
|
dcn.MonitoredItems.Count,
|
|
Is.LessThanOrEqualTo((int)revisedQueue + 1),
|
|
"Should accumulate up to queue size values");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
RapidChangesOverflowDiscardOldest()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 42,
|
|
samplingInterval: 50,
|
|
queueSize: 2,
|
|
discardOldest: true))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Write more values than queue can hold
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(80000 + i))
|
|
.ConfigureAwait(false);
|
|
await Task.Delay(60).ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Should get notifications after overflow");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
RapidChangesOverflowDiscardNewest()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 43,
|
|
samplingInterval: 50,
|
|
queueSize: 2,
|
|
discardOldest: false))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(90000 + i))
|
|
.ConfigureAwait(false);
|
|
await Task.Delay(60).ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Should get notifications with discard-newest");
|
|
}
|
|
|
|
[Test]
|
|
public async Task OverflowBitSetOnQueueOverflowAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(nodeId, 44,
|
|
samplingInterval: 50,
|
|
queueSize: 1,
|
|
discardOldest: true))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Force overflow with many rapid writes
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
await WriteVariantAsync(
|
|
nodeId, new Variant(100000 + i))
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0));
|
|
|
|
// Check if any item has the overflow bit set
|
|
bool hasOverflow = dcn.MonitoredItems.ToArray()!.Any(
|
|
m => m.Value.StatusCode.Overflow);
|
|
// Server may or may not set overflow on queue size 1;
|
|
// just verify we got data without error
|
|
Assert.That(
|
|
dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"Should have notification items after overflow");
|
|
|
|
if (hasOverflow)
|
|
{
|
|
Assert.Pass("Overflow bit correctly set.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
FiveItemsOnDifferentNodesAllNotify()
|
|
{
|
|
var nodeIds = new NodeId[]
|
|
{
|
|
ToNodeId(Constants.ScalarStaticInt32),
|
|
ToNodeId(Constants.ScalarStaticDouble),
|
|
ToNodeId(Constants.ScalarStaticString),
|
|
ToNodeId(Constants.ScalarStaticBoolean),
|
|
ToNodeId(Constants.ScalarStaticFloat)
|
|
};
|
|
|
|
var items = new List<MonitoredItemCreateRequest>();
|
|
for (int i = 0; i < nodeIds.Length; i++)
|
|
{
|
|
items.Add(CreateItemRequest(
|
|
nodeIds[i], (uint)(50 + i),
|
|
samplingInterval: 50));
|
|
}
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await Session.CreateMonitoredItemsAsync(
|
|
null, m_subscriptionId, TimestampsToReturn.Both,
|
|
items.ToArray().ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
createResp.Results.Count, Is.EqualTo(5));
|
|
foreach (MonitoredItemCreateResult r in createResp.Results)
|
|
{
|
|
Assert.That(
|
|
StatusCode.IsGood(r.StatusCode), Is.True);
|
|
}
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
// Write to all nodes
|
|
var writeValues = new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = nodeIds[0],
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(111))
|
|
},
|
|
new() {
|
|
NodeId = nodeIds[1],
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(2.22))
|
|
},
|
|
new() {
|
|
NodeId = nodeIds[2],
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(
|
|
new Variant("multi_" + DateTime.UtcNow.Ticks))
|
|
},
|
|
new() {
|
|
NodeId = nodeIds[3],
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(true))
|
|
},
|
|
new() {
|
|
NodeId = nodeIds[4],
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(3.14f))
|
|
}
|
|
};
|
|
|
|
WriteResponse writeResp = await Session.WriteAsync(
|
|
null, writeValues.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
foreach (StatusCode sc in writeResp.Results)
|
|
{
|
|
Assert.That(StatusCode.IsGood(sc), Is.True);
|
|
}
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
Assert.That(dcn.MonitoredItems.Count, Is.GreaterThan(0),
|
|
"All five items should have notified");
|
|
}
|
|
|
|
[Test]
|
|
public async Task TwoItemsOnSameNodeBothNotifyAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var items = new MonitoredItemCreateRequest[]
|
|
{
|
|
CreateItemRequest(nodeId, 60, samplingInterval: 50),
|
|
CreateItemRequest(nodeId, 61, samplingInterval: 50)
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await Session.CreateMonitoredItemsAsync(
|
|
null, m_subscriptionId, TimestampsToReturn.Both,
|
|
items.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(createResp.Results.Count, Is.EqualTo(2));
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[1].StatusCode),
|
|
Is.True);
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId, new Variant(44444))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
// Both monitored items should have notifications
|
|
var handles = dcn.MonitoredItems.ToArray()!
|
|
.Select(m => m.ClientHandle).ToList();
|
|
Assert.That(handles, Does.Contain(60u),
|
|
"First item should be notified");
|
|
Assert.That(handles, Does.Contain(61u),
|
|
"Second item should be notified");
|
|
}
|
|
|
|
[Test]
|
|
public async Task
|
|
ItemsWithDifferentSamplingIntervals()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
var items = new MonitoredItemCreateRequest[]
|
|
{
|
|
CreateItemRequest(nodeId, 70, samplingInterval: 50),
|
|
CreateItemRequest(nodeId, 71, samplingInterval: 5000)
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await Session.CreateMonitoredItemsAsync(
|
|
null, m_subscriptionId, TimestampsToReturn.Both,
|
|
items.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(createResp.Results.Count, Is.EqualTo(2));
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[0].StatusCode),
|
|
Is.True);
|
|
Assert.That(
|
|
StatusCode.IsGood(createResp.Results[1].StatusCode),
|
|
Is.True);
|
|
|
|
double revisedFast =
|
|
createResp.Results[0].RevisedSamplingInterval;
|
|
double revisedSlow =
|
|
createResp.Results[1].RevisedSamplingInterval;
|
|
|
|
// The server should honor or revise differently
|
|
Assert.That(revisedSlow,
|
|
Is.GreaterThanOrEqualTo(revisedFast),
|
|
"Slow interval should be >= fast interval");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ItemsWithDifferentClientHandlesAsync()
|
|
{
|
|
NodeId nodeId1 = ToNodeId(Constants.ScalarStaticInt32);
|
|
NodeId nodeId2 = ToNodeId(Constants.ScalarStaticDouble);
|
|
|
|
var items = new MonitoredItemCreateRequest[]
|
|
{
|
|
CreateItemRequest(nodeId1, 80, samplingInterval: 50),
|
|
CreateItemRequest(nodeId2, 81, samplingInterval: 50)
|
|
};
|
|
|
|
CreateMonitoredItemsResponse createResp =
|
|
await Session.CreateMonitoredItemsAsync(
|
|
null, m_subscriptionId, TimestampsToReturn.Both,
|
|
items.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(createResp.Results.Count, Is.EqualTo(2));
|
|
|
|
await DrainPublishAsync().ConfigureAwait(false);
|
|
|
|
await WriteVariantAsync(nodeId1, new Variant(55555))
|
|
.ConfigureAwait(false);
|
|
await WriteVariantAsync(nodeId2, new Variant(6.66))
|
|
.ConfigureAwait(false);
|
|
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(500).ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null);
|
|
|
|
foreach (MonitoredItemNotification item
|
|
in dcn.MonitoredItems)
|
|
{
|
|
Assert.That(
|
|
item.ClientHandle,
|
|
Is.AnyOf(80u, 81u),
|
|
"ClientHandle must match requested value");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateAndDeleteItemRepeatedlyAsync()
|
|
{
|
|
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
|
|
|
for (int round = 0; round < 3; round++)
|
|
{
|
|
CreateMonitoredItemsResponse createResp =
|
|
await CreateSingleItemAsync(
|
|
CreateItemRequest(
|
|
nodeId, (uint)(90 + round),
|
|
samplingInterval: 50))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(
|
|
createResp.Results[0].StatusCode),
|
|
Is.True,
|
|
$"Round {round}: create failed");
|
|
|
|
uint monitoredItemId =
|
|
createResp.Results[0].MonitoredItemId;
|
|
|
|
// Get initial notification
|
|
DataChangeNotification dcn =
|
|
await PublishAndGetDcnAsync(300)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(dcn, Is.Not.Null,
|
|
$"Round {round}: no initial DCN");
|
|
|
|
// Delete the item
|
|
DeleteMonitoredItemsResponse deleteResp =
|
|
await Session.DeleteMonitoredItemsAsync(
|
|
null,
|
|
m_subscriptionId,
|
|
new uint[] { monitoredItemId }.ToArrayOf(),
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(deleteResp.Results[0]),
|
|
Is.True,
|
|
$"Round {round}: delete failed");
|
|
}
|
|
}
|
|
|
|
private MonitoredItemCreateRequest CreateItemRequest(
|
|
NodeId nodeId,
|
|
uint clientHandle,
|
|
double samplingInterval = 100,
|
|
uint queueSize = 10,
|
|
MonitoringMode mode = MonitoringMode.Reporting,
|
|
uint attributeId = Attributes.Value,
|
|
bool discardOldest = true,
|
|
ExtensionObject filter = default)
|
|
{
|
|
return new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = attributeId
|
|
},
|
|
MonitoringMode = mode,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = clientHandle,
|
|
SamplingInterval = samplingInterval,
|
|
Filter = filter,
|
|
DiscardOldest = discardOldest,
|
|
QueueSize = queueSize
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task WriteVariantAsync(
|
|
NodeId nodeId, Variant value)
|
|
{
|
|
WriteResponse writeResp = await Session.WriteAsync(
|
|
null,
|
|
new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(value)
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(writeResp.Results[0]), Is.True,
|
|
$"Write to {nodeId} failed: {writeResp.Results[0]}");
|
|
}
|
|
|
|
private async Task<bool> TryWriteVariantAsync(
|
|
NodeId nodeId, Variant value)
|
|
{
|
|
WriteResponse writeResp = await Session.WriteAsync(
|
|
null,
|
|
new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(value)
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
return StatusCode.IsGood(writeResp.Results[0]);
|
|
}
|
|
|
|
private async Task<DataChangeNotification> PublishAndGetDcnAsync(
|
|
int delayMs = 300)
|
|
{
|
|
await Task.Delay(delayMs).ConfigureAwait(false);
|
|
|
|
PublishResponse pubResp = await Session.PublishWithTimeoutAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
StatusCode.IsGood(pubResp.ResponseHeader.ServiceResult),
|
|
Is.True);
|
|
|
|
if (pubResp.NotificationMessage.NotificationData.Count == 0)
|
|
{
|
|
return null!;
|
|
}
|
|
|
|
return (ExtensionObject.ToEncodeable(
|
|
pubResp.NotificationMessage.NotificationData[0]) as
|
|
DataChangeNotification)!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drain all pending publish responses so subsequent tests
|
|
/// start from a clean state.
|
|
/// </summary>
|
|
private async Task DrainPublishAsync()
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
await Session.PublishWithTimeoutAsync().ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException)
|
|
{
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
private Variant GetTestValueForIndex(int index)
|
|
{
|
|
return index switch
|
|
{
|
|
0 => new Variant(true),
|
|
1 => new Variant((sbyte)42),
|
|
2 => new Variant((byte)42),
|
|
3 => new Variant((short)4200),
|
|
4 => new Variant((ushort)4200),
|
|
5 => new Variant(42000),
|
|
6 => new Variant(42000u),
|
|
7 => new Variant(420000L),
|
|
8 => new Variant(420000UL),
|
|
9 => new Variant(42.5f),
|
|
10 => new Variant(42.5),
|
|
11 => new Variant("TestValue_" + DateTime.UtcNow.Ticks),
|
|
12 => new Variant(DateTime.UtcNow),
|
|
13 => new Variant(new Uuid(Guid.NewGuid())),
|
|
14 => new Variant(new byte[] { 1, 2, 3 }),
|
|
15 => new Variant(new NodeId("test", 0)),
|
|
16 => new Variant(new LocalizedText("en", "test")),
|
|
17 => new Variant(new QualifiedName("test")),
|
|
18 => new Variant(123),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
};
|
|
}
|
|
|
|
private uint m_subscriptionId;
|
|
|
|
private async Task<CreateMonitoredItemsResponse>
|
|
CreateSingleItemAsync(MonitoredItemCreateRequest item)
|
|
{
|
|
return await Session.CreateMonitoredItemsAsync(
|
|
null,
|
|
m_subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
new MonitoredItemCreateRequest[] { item }.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|