mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 15:11:53 +08:00
# Description Fixes four CTT (OPC UA Compliance Test Tool) conformance gaps that the existing NUnit parity suite was silently passing while the CTT JS scripts kept failing: | Issue | Area | Failing CTT script | |---|---|---| | #3719 | Base Info Core Structure 2 | `001.js` | | #3720 | Base Info FiniteStateMachine + StateMachine Instance | `initialize.js` + `001.js` | | #3723 | Security None | `007.js` (DoS, partially used channels) | | #3841 | Alarms & Conditions | Acknowledge Test_003, Alarm Test_002, Confirm Test_001 / Test_003 / Err_004, Enable Test_002, Non-Exclusive Limit Test_002 | Across all four, the underlying pattern was the same — the NUnit parity tests were checking weaker invariants (DataType attribute instead of encoded Variant BuiltInType; `Assert.Ignore` instead of `Assert.Fail`; stub `GetEndpoints` instead of the real DoS scenario; method-call + Read instead of subscribe-and-validate-event-payload). So the suite gave false confidence on changes that would have failed CTT. Each commit below both **strengthens the NUnit parity test** to encode the same invariant CTT verifies, **and** fixes the server/model defect that the strengthened test surfaces. ## Commits ### `a1db2984c` Fix #3719 (BaseInfoCoreStructure) - **Test**: `BaseInfoServerTests.ReadConformanceUnitsAsync` now reads the **value** of `i=24101` and asserts `WrappedValue.TypeInfo.BuiltInType == BuiltInType.QualifiedName` AND `ValueRank == ValueRanks.OneDimension` — not just the DataType attribute. - **Test**: `BaseInformationTests.BrowseRequiresMandatoryMethodsAsync` now `Assert.Fail`s (not `Assert.Ignore`) when an expected mandatory method is missing on `i=15443` / `i=25440`. - **Server**: `Server_ServerCapabilities_ConformanceUnits` initialised with `Array.Empty<QualifiedName>().ToArrayOf()` so the encoded Variant carries QualifiedName type info even when empty. - **Server**: Added `AddSecurityGroup` / `RemoveSecurityGroup` (on `i=15443`) and `AddPushTarget` / `RemovePushTarget` (on `i=25440`) method nodes with input/output argument metadata and in-memory handlers in `Quickstarts.Servers/ReferenceServerConfigurationNodeManager`. ### `86d3041e5` Fix #3723 (Security None DoS off-by-one) - **Server**: Correct off-by-one in `Stack/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs` channel eviction: - Condition was `MaxChannelCount == channelCount` (only triggered at exact equality); now `MaxChannelCount > 0 && channelCount >= MaxChannelCount` with a loop that evicts idle channels until the count is below the limit or no more idle channels remain. - **Server**: `UsedBySession` now properly tracks active sessions and clears on `CloseSession` (`TcpServerChannel`, `TcpListenerChannel`). - **Note**: the NUnit reproducer for this issue (`SecurityNoneDosTests`) stays on the `cttunit` branch (PR #3750) because it depends on the `TestFixture.CloseSharedFixtureSessionAsync` helper that only exists in `Tests/Opc.Ua.Conformance.Tests/`. The server fix on this branch is sufficient to make the CTT JS script pass. ### `2c98390c9` Fix #3720 (BaseInfoStateMachine GeneratesEvent) - **Model**: Add `GeneratesEvent` forward reference on `StateMachineType` (i=2299) and `FiniteStateMachineType` (i=2771) → `AuditUpdateStateEventType` (i=2315) in the design XML (`Tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml`); regenerated `Stack/Opc.Ua.Core/Schema/Opc.Ua.NodeSet2.xml`. - **Server**: Remove the runtime workaround in `DiagnosticsNodeManager` that previously injected this reference at load time — now redundant because the reference ships in the NodeSet at compile time. - **Test**: New `BaseInfoStateMachineInstanceTests.StateMachineTypeHasGeneratesEventReferenceAsync` walks the type hierarchy looking for `GeneratesEvent` with `IncludeSubtypes=true` and asserts the target resolves to a `BaseEventType` subtype (matches the CTT walk). - Per OPC UA Part 16 §4.4.5 / Part 5 §6.4.2. ### `09f551449` Strengthen A&C conformance event tests (#3841) - **Test fixture**: New `AlarmEventCollector` (476 LOC) — subscribes to events on `i=Server` with a full A&C event filter (`Comment`, `Retain`, transition times, all condition-state Ids); per-condition event log; `WaitForEventAsync(conditionId, predicate, timeout)` for synchronisation. Uses async `IAsyncDisposable` cleanup. - **Tests rewritten** to subscribe to events and validate event payload (`Comment.Text`, `Retain`, `TransitionTime`): - `Acknowledge Test_003` — empty comment + locale propagates to event payload; `Retain=false` afterwards - `Alarm Test_002` — full `ActiveState` cycle Active → Normal with Acknowledge + Confirm; every transition event validated - `Confirm Test_001` — unique comment reaches event payload; `ConfirmedState/Id=true` - `Confirm Test_003` — Acknowledge → wait event → Confirm with empty comment - `Confirm Err_004` — **two concurrent sessions**; second Confirm asserts specifically `BadConditionBranchAlreadyConfirmed` (not a generic `BadInvalidState`) - `Enable Test_002` — Disable + Enable via both type method and discovered instance method; NO events during disabled period; `TransitionTime` within call window; `Retain` flips - `Non-Exclusive Limit Test_002` — mirror for `NonExclusiveLimitAlarmType` - **Server fixes** in `Stack/Opc.Ua.Core.Types/State/AcknowledgeableConditionState.cs`, `Quickstarts.Servers/Alarms/AlarmHolders/*`, and `AlarmNodeManager` for: Comment propagation, Retain clearing, TransitionTime updates, and a dedicated ack-only condition instance (`AcknowledgeableConditionHolder`). ## Verification - `dotnet build UA.slnx` — **0 errors**, 42 pre-existing warnings. - `dotnet test Tests/Opc.Ua.InformationModel.Tests` filtered for the new + master `StateMachineGeneratesEvent` / `BaseInfoServer` / `BaseInformation` tests — **6/6 pass**. - `dotnet test Tests/Opc.Ua.History.Tests --filter AlarmsAndConditions` — **141/144 pass**; the 3 occasional failures in `DisableEnableViaTypeAndInstanceMethodNoEventsDuringDisabledAsync` pass when re-run in isolation, consistent with the known A&C suite CI flakiness pattern. ## Related Issues - Fixes #3719 - Fixes #3720 - Fixes #3723 - Fixes #3841 Related: PR #3750 (`cttunit`) carries the same fixes plus the conformance-test-project versions of the strengthened NUnit tests. Either this PR or that one will land first; the other should be rebased afterwards. ## 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. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] 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. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
691 lines
26 KiB
C#
691 lines
26 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.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.TestFramework;
|
|
|
|
namespace Opc.Ua.History.Tests
|
|
{
|
|
/// <summary>
|
|
/// Base fixture for Alarms & Conditions tests. Discovers alarm
|
|
/// instances exposed by the AlarmNodeManager (started via
|
|
/// <c>ApplyCTTModeAsync</c>) and provides shared helpers for
|
|
/// browsing, reading state, and calling condition methods.
|
|
/// </summary>
|
|
public abstract class AlarmsAndConditionsTestFixture : TestFixture
|
|
{
|
|
/// <summary>
|
|
/// NodeId of the Alarms folder created by AlarmNodeManager.
|
|
/// Discovered on first use by browsing the Objects folder.
|
|
/// </summary>
|
|
protected NodeId AlarmsFolderId
|
|
{
|
|
get
|
|
{
|
|
if (!m_alarmsFolderDiscovered)
|
|
{
|
|
m_alarmsFolderId = DiscoverAlarmsFolderAsync()
|
|
.GetAwaiter().GetResult();
|
|
m_alarmsFolderDiscovered = true;
|
|
}
|
|
return m_alarmsFolderId;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cached alarm instances discovered under the Alarms folder.
|
|
/// Maps alarm BrowseName to NodeId.
|
|
/// </summary>
|
|
protected IReadOnlyDictionary<string, NodeId> AlarmInstances
|
|
{
|
|
get
|
|
{
|
|
m_alarmInstances ??= DiscoverAlarmInstancesAsync()
|
|
.GetAwaiter().GetResult();
|
|
return m_alarmInstances;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the first alarm instance whose BrowseName starts with
|
|
/// the supplied alarm type name. Returns NodeId.Null when no
|
|
/// matching alarm is found.
|
|
/// </summary>
|
|
protected NodeId FindAlarmByTypeName(string alarmTypeName)
|
|
{
|
|
foreach (KeyValuePair<string, NodeId> kvp in AlarmInstances)
|
|
{
|
|
if (kvp.Key.StartsWith(
|
|
alarmTypeName, StringComparison.Ordinal))
|
|
{
|
|
return kvp.Value;
|
|
}
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns any active alarm condition NodeId, or NodeId.Null
|
|
/// when none has been discovered.
|
|
/// </summary>
|
|
protected NodeId FindAnyAlarm()
|
|
{
|
|
foreach (NodeId id in AlarmInstances.Values)
|
|
{
|
|
return id;
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Skips the test with Assert.Ignore if no live alarm instance
|
|
/// can be located in the address space.
|
|
/// </summary>
|
|
protected NodeId RequireAlarm(string typeName = null)
|
|
{
|
|
NodeId alarmId = typeName != null
|
|
? FindAlarmByTypeName(typeName)
|
|
: FindAnyAlarm();
|
|
if (alarmId.IsNull)
|
|
{
|
|
Assert.Ignore(
|
|
"Server does not expose a live alarm condition " +
|
|
"instance for this test.");
|
|
}
|
|
return alarmId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns an alarm for a CTT parity test or marks the test
|
|
/// inconclusive when the reference server does not expose one.
|
|
/// </summary>
|
|
protected NodeId RequireCttAlarm(string typeName = null)
|
|
{
|
|
NodeId alarmId = typeName != null
|
|
? FindAlarmByTypeName(typeName)
|
|
: FindAnyAlarm();
|
|
if (alarmId.IsNull)
|
|
{
|
|
Assert.Inconclusive(
|
|
"Server does not expose a live alarm condition " +
|
|
"instance for this CTT parity test.");
|
|
}
|
|
return alarmId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the boolean Id of a TwoStateVariable child by browse
|
|
/// name (e.g. "AckedState" -> reads "AckedState/Id"). Returns
|
|
/// the DataValue from the server.
|
|
/// </summary>
|
|
protected async Task<DataValue> ReadStateIdAsync(
|
|
NodeId conditionId, string stateName)
|
|
{
|
|
NodeId stateId = await TranslateBrowsePathAsync(
|
|
conditionId, stateName, "Id").ConfigureAwait(false);
|
|
if (stateId.IsNull)
|
|
{
|
|
return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
|
|
}
|
|
return await ReadAttributeAsync(stateId, Attributes.Value)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a child variable's value by relative browse name path.
|
|
/// </summary>
|
|
protected async Task<DataValue> ReadChildValueAsync(
|
|
NodeId parent, params string[] path)
|
|
{
|
|
NodeId targetId = await TranslateBrowsePathAsync(
|
|
parent, path).ConfigureAwait(false);
|
|
if (targetId.IsNull)
|
|
{
|
|
return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
|
|
}
|
|
return await ReadAttributeAsync(targetId, Attributes.Value)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the alarm's current EventId (used as input to
|
|
/// Acknowledge / Confirm / AddComment method calls).
|
|
/// </summary>
|
|
protected async Task<ByteString> ReadEventIdAsync(NodeId conditionId)
|
|
{
|
|
DataValue dv = await ReadChildValueAsync(conditionId, "EventId")
|
|
.ConfigureAwait(false);
|
|
if (StatusCode.IsBad(dv.StatusCode))
|
|
{
|
|
return default;
|
|
}
|
|
if (dv.WrappedValue.TryGetValue(out ByteString eventId))
|
|
{
|
|
return eventId;
|
|
}
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls a method on the supplied condition object and returns
|
|
/// the CallMethodResult.
|
|
/// </summary>
|
|
protected async Task<CallMethodResult> CallMethodOnAlarmAsync(
|
|
NodeId conditionId,
|
|
NodeId methodId,
|
|
params Variant[] inputArguments)
|
|
{
|
|
return await CallMethodOnSessionAsync(
|
|
Session,
|
|
conditionId,
|
|
methodId,
|
|
inputArguments).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls a method on the supplied session and returns the result.
|
|
/// </summary>
|
|
protected static async Task<CallMethodResult> CallMethodOnSessionAsync(
|
|
ISession session,
|
|
NodeId objectId,
|
|
NodeId methodId,
|
|
params Variant[] inputArguments)
|
|
{
|
|
CallResponse response = await session.CallAsync(
|
|
null,
|
|
new CallMethodRequest[]
|
|
{
|
|
new() {
|
|
ObjectId = objectId,
|
|
MethodId = methodId,
|
|
InputArguments = inputArguments.ToArrayOf()
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results.Count, Is.EqualTo(1));
|
|
return response.Results[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads any attribute of a node.
|
|
/// </summary>
|
|
protected async Task<DataValue> ReadAttributeAsync(
|
|
NodeId nodeId, uint attributeId)
|
|
{
|
|
ReadResponse response = await Session.ReadAsync(
|
|
null, 0, TimestampsToReturn.Both,
|
|
new ReadValueId[]
|
|
{
|
|
new() {
|
|
NodeId = nodeId,
|
|
AttributeId = attributeId
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results.Count, Is.EqualTo(1));
|
|
return response.Results[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browses the supplied node forward (HierarchicalReferences).
|
|
/// </summary>
|
|
protected async Task<BrowseResult> BrowseForwardAsync(NodeId nodeId)
|
|
{
|
|
BrowseResponse response = await Session.BrowseAsync(
|
|
null, null, 0,
|
|
new BrowseDescription[]
|
|
{
|
|
new() {
|
|
NodeId = nodeId,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId =
|
|
ReferenceTypeIds.HierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results.Count, Is.EqualTo(1));
|
|
return response.Results[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an instance method child by BrowseName.
|
|
/// </summary>
|
|
protected async Task<NodeId> FindInstanceMethodAsync(
|
|
NodeId conditionId,
|
|
string methodName)
|
|
{
|
|
BrowseResult result = await BrowseForwardAsync(conditionId).ConfigureAwait(false);
|
|
int count = result.References.Count;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
ReferenceDescription reference = result.References[i];
|
|
if (reference.NodeClass == NodeClass.Method &&
|
|
reference.BrowseName.Name == methodName)
|
|
{
|
|
return ToNodeId(reference.NodeId);
|
|
}
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the source variable that exposes the condition via HasCondition.
|
|
/// </summary>
|
|
protected async Task<NodeId> FindConditionSourceAsync(NodeId conditionId)
|
|
{
|
|
BrowseResult folder = await BrowseForwardAsync(AlarmsFolderId).ConfigureAwait(false);
|
|
int sourceCount = folder.References.Count;
|
|
for (int i = 0; i < sourceCount; i++)
|
|
{
|
|
ReferenceDescription sourceReference = folder.References[i];
|
|
if (sourceReference.NodeClass != NodeClass.Variable)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
NodeId sourceId = ToNodeId(sourceReference.NodeId);
|
|
BrowseResponse response = await Session.BrowseAsync(
|
|
null,
|
|
null,
|
|
0,
|
|
new BrowseDescription[]
|
|
{
|
|
new() {
|
|
NodeId = sourceId,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HasCondition,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
if (response.Results.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int conditionCount = response.Results[0].References.Count;
|
|
for (int j = 0; j < conditionCount; j++)
|
|
{
|
|
if (ToNodeId(response.Results[0].References[j].NodeId) == conditionId)
|
|
{
|
|
return sourceId;
|
|
}
|
|
}
|
|
}
|
|
|
|
return NodeId.Null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a value to the alarm source variable that drives a condition.
|
|
/// </summary>
|
|
protected async Task WriteAlarmSourceValueAsync(NodeId conditionId, Variant value)
|
|
{
|
|
NodeId sourceId = await FindConditionSourceAsync(conditionId).ConfigureAwait(false);
|
|
if (sourceId.IsNull)
|
|
{
|
|
Assert.Inconclusive(
|
|
$"Condition {conditionId} is not linked to a writable alarm source.");
|
|
}
|
|
|
|
WriteResponse response = await Session.WriteAsync(
|
|
null,
|
|
new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = sourceId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(value)
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results.Count, Is.EqualTo(1));
|
|
Assert.That(StatusCode.IsGood(response.Results[0]), Is.True,
|
|
$"Writing alarm source {sourceId} should succeed: {response.Results[0]}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort reset to a normal, acknowledged and confirmed state.
|
|
/// </summary>
|
|
protected async Task NormalizeAlarmAsync(NodeId conditionId)
|
|
{
|
|
await CallMethodOnAlarmAsync(
|
|
conditionId,
|
|
MethodIds.ConditionType_Enable).ConfigureAwait(false);
|
|
|
|
await WriteAlarmSourceValueAsync(conditionId, new Variant(50)).ConfigureAwait(false);
|
|
await AcknowledgeCurrentEventIfRequiredAsync(conditionId).ConfigureAwait(false);
|
|
await ConfirmCurrentEventIfRequiredAsync(conditionId).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a state variable as Boolean.
|
|
/// </summary>
|
|
protected async Task<bool> ReadStateBoolAsync(NodeId conditionId, string stateName)
|
|
{
|
|
DataValue state = await ReadStateIdAsync(conditionId, stateName).ConfigureAwait(false);
|
|
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True,
|
|
$"Should be able to read {stateName}/Id.");
|
|
Assert.That(state.WrappedValue.TryGetValue(out bool value), Is.True,
|
|
$"{stateName}/Id should be Boolean.");
|
|
return value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translates a relative browse path (a sequence of browse names)
|
|
/// from a starting node to the resolved NodeId. Returns
|
|
/// NodeId.Null when the path cannot be translated.
|
|
/// </summary>
|
|
protected async Task<NodeId> TranslateBrowsePathAsync(
|
|
NodeId startingNode, params string[] segments)
|
|
{
|
|
var elementsArray = new RelativePathElement[segments.Length];
|
|
for (int i = 0; i < segments.Length; i++)
|
|
{
|
|
elementsArray[i] = new RelativePathElement
|
|
{
|
|
ReferenceTypeId =
|
|
ReferenceTypeIds.HierarchicalReferences,
|
|
IsInverse = false,
|
|
IncludeSubtypes = true,
|
|
TargetName = new QualifiedName(
|
|
segments[i], FindBrowseNameNamespace(segments[i]))
|
|
};
|
|
}
|
|
|
|
var browsePath = new BrowsePath
|
|
{
|
|
StartingNode = startingNode,
|
|
RelativePath = new RelativePath
|
|
{
|
|
Elements = elementsArray.ToArrayOf()
|
|
}
|
|
};
|
|
|
|
TranslateBrowsePathsToNodeIdsResponse response =
|
|
await Session.TranslateBrowsePathsToNodeIdsAsync(
|
|
null,
|
|
new BrowsePath[] { browsePath }.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
if (response.Results.Count == 0)
|
|
{
|
|
return NodeId.Null;
|
|
}
|
|
BrowsePathResult result = response.Results[0];
|
|
if (StatusCode.IsBad(result.StatusCode) ||
|
|
result.Targets.Count == 0)
|
|
{
|
|
return NodeId.Null;
|
|
}
|
|
return ToNodeId(result.Targets[0].TargetId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// All standard condition properties (EventId, AckedState,
|
|
/// ConfirmedState, EnabledState, Comment, ShelvingState, etc.)
|
|
/// live in the OPC UA core namespace.
|
|
/// </summary>
|
|
protected static ushort FindBrowseNameNamespace(string name)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers the Alarms folder by browsing the Objects folder.
|
|
/// </summary>
|
|
private async Task<NodeId> DiscoverAlarmsFolderAsync()
|
|
{
|
|
BrowseResult result = await BrowseForwardAsync(
|
|
ObjectIds.ObjectsFolder).ConfigureAwait(false);
|
|
|
|
int count = result.References.Count;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
ReferenceDescription r = result.References[i];
|
|
if (r.BrowseName.Name == "Alarms")
|
|
{
|
|
return ToNodeId(r.NodeId);
|
|
}
|
|
}
|
|
return NodeId.Null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browses forward from the Alarms folder and returns nodes
|
|
/// whose type definition is a subtype of ConditionType. Alarm
|
|
/// conditions are reachable via the HasCondition reference from
|
|
/// the source variables (AnalogSource, BooleanSource, etc.).
|
|
/// </summary>
|
|
private async Task<Dictionary<string, NodeId>> DiscoverAlarmInstancesAsync()
|
|
{
|
|
var instances = new Dictionary<string, NodeId>();
|
|
NodeId folder = AlarmsFolderId;
|
|
if (folder.IsNull)
|
|
{
|
|
return instances;
|
|
}
|
|
|
|
BrowseResult result = await BrowseForwardAsync(folder)
|
|
.ConfigureAwait(false);
|
|
|
|
// First pass: collect every variable child and the direct
|
|
// hierarchical Object children (in case the alarms are
|
|
// exposed both ways).
|
|
var sourceCandidates = new List<NodeId>();
|
|
var directCandidates = new List<(string, NodeId, NodeId)>();
|
|
int count = result.References.Count;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
ReferenceDescription r = result.References[i];
|
|
NodeId nodeId = ToNodeId(r.NodeId);
|
|
if (nodeId.IsNull)
|
|
{
|
|
continue;
|
|
}
|
|
if (r.NodeClass == NodeClass.Variable)
|
|
{
|
|
sourceCandidates.Add(nodeId);
|
|
}
|
|
else if (r.NodeClass == NodeClass.Object)
|
|
{
|
|
NodeId typeDef = ToNodeId(r.TypeDefinition);
|
|
if (!typeDef.IsNull)
|
|
{
|
|
directCandidates.Add((r.BrowseName.Name, nodeId, typeDef));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Browse each source via HasCondition for alarm targets.
|
|
foreach (NodeId source in sourceCandidates)
|
|
{
|
|
BrowseResponse resp = await Session.BrowseAsync(
|
|
null, null, 0,
|
|
new BrowseDescription[]
|
|
{
|
|
new() {
|
|
NodeId = source,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HasCondition,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
if (resp.Results.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
int condCount = resp.Results[0].References.Count;
|
|
for (int i = 0; i < condCount; i++)
|
|
{
|
|
ReferenceDescription r = resp.Results[0].References[i];
|
|
NodeId nodeId = ToNodeId(r.NodeId);
|
|
if (nodeId.IsNull)
|
|
{
|
|
continue;
|
|
}
|
|
if (!instances.ContainsKey(r.BrowseName.Name))
|
|
{
|
|
instances[r.BrowseName.Name] = nodeId;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also include any objects that are direct children whose
|
|
// type-def is a ConditionType subtype.
|
|
foreach ((string name, NodeId nodeId, NodeId typeDef) in directCandidates)
|
|
{
|
|
if (await IsConditionSubtypeAsync(typeDef)
|
|
.ConfigureAwait(false))
|
|
{
|
|
instances[name] = nodeId;
|
|
}
|
|
}
|
|
|
|
return instances;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks the supertype chain of <paramref name="typeId"/> until
|
|
/// it reaches ConditionType or a known root. Returns true if
|
|
/// ConditionType is encountered.
|
|
/// </summary>
|
|
private async Task<bool> IsConditionSubtypeAsync(NodeId typeId)
|
|
{
|
|
NodeId current = typeId;
|
|
for (int hop = 0; hop < 10 && !current.IsNull; hop++)
|
|
{
|
|
if (current == ObjectTypeIds.ConditionType)
|
|
{
|
|
return true;
|
|
}
|
|
if (current == ObjectTypeIds.BaseObjectType ||
|
|
current == ObjectTypeIds.BaseEventType)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
BrowseResponse response = await Session.BrowseAsync(
|
|
null, null, 0,
|
|
new BrowseDescription[]
|
|
{
|
|
new() {
|
|
NodeId = current,
|
|
BrowseDirection = BrowseDirection.Inverse,
|
|
ReferenceTypeId =
|
|
ReferenceTypeIds.HasSubtype,
|
|
IncludeSubtypes = false,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
}.ToArrayOf(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
if (response.Results.Count == 0 ||
|
|
response.Results[0].References.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
current = ToNodeId(response.Results[0].References[0].NodeId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async Task AcknowledgeCurrentEventIfRequiredAsync(NodeId conditionId)
|
|
{
|
|
bool acked = await ReadStateBoolAsync(conditionId, "AckedState").ConfigureAwait(false);
|
|
if (acked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ByteString eventId = await ReadEventIdAsync(conditionId).ConfigureAwait(false);
|
|
if (!eventId.IsNull)
|
|
{
|
|
await CallMethodOnAlarmAsync(
|
|
conditionId,
|
|
MethodIds.AcknowledgeableConditionType_Acknowledge,
|
|
new Variant(eventId),
|
|
new Variant(new LocalizedText("en", "normalize"))).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task ConfirmCurrentEventIfRequiredAsync(NodeId conditionId)
|
|
{
|
|
NodeId confirmedStateId = await TranslateBrowsePathAsync(
|
|
conditionId,
|
|
"ConfirmedState",
|
|
"Id").ConfigureAwait(false);
|
|
if (confirmedStateId.IsNull)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool confirmed = await ReadStateBoolAsync(conditionId, "ConfirmedState").ConfigureAwait(false);
|
|
if (confirmed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ByteString eventId = await ReadEventIdAsync(conditionId).ConfigureAwait(false);
|
|
if (!eventId.IsNull)
|
|
{
|
|
await CallMethodOnAlarmAsync(
|
|
conditionId,
|
|
MethodIds.AcknowledgeableConditionType_Confirm,
|
|
new Variant(eventId),
|
|
new Variant(new LocalizedText("en", "normalize"))).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private NodeId m_alarmsFolderId;
|
|
private bool m_alarmsFolderDiscovered;
|
|
private Dictionary<string, NodeId> m_alarmInstances;
|
|
}
|
|
}
|