Files
UA-.NETStandard/Tests/Opc.Ua.History.Tests/AlarmsAndConditionsTestFixture.cs
Marc SchierandCopilot bc4c30034d Fix CTT conformance issues #3719, #3720, #3723, #3841 (#3851)
# 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>
2026-06-08 07:47:48 +02:00

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 &amp; 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" -&gt; 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;
}
}