mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
87012a147f
# Description - Add opt-in bounded browsing and strict reads with original source, status, timestamp, and structured-value evidence. Preserve the existing tolerant constructors and convenience projections, and wire options through session extensions, factories, and hosting registration. - Add selected-result streaming downloads with byte/request limits and independent cleanup, using `TemporaryFileTransferClient.CopyToAsync` from the client/server predecessor. Retain the detailed transfer regressions in the Machinery test layer. - Expose genuinely null unpublished result slots, carry the two Machinery sample updates, and include the complete Machinery guide and package README changes. ## Related Issues - Related tracking issue: #4461; this extraction does not close it. - Frozen source PR: #4462; this extraction does not close or modify it. - Immediate predecessor: #4803 (`marcschier-extract-amb-changes`). The earlier dependencies are client/server #4802 and core/types #4801. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] 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.
449 lines
20 KiB
C#
449 lines
20 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using EcmBrowseNames = Opc.Ua.ECM.BrowseNames;
|
|
using MachineryBrowseNames = Opc.Ua.Machinery.BrowseNames;
|
|
|
|
namespace Opc.Ua.Machinery.Client
|
|
{
|
|
public sealed partial class MachineryClient
|
|
{
|
|
/// <summary>
|
|
/// Resolves a machinery item's <c>Monitoring/Consumption</c> folder,
|
|
/// the entry point OPC 40001-4 puts energy information under, or
|
|
/// <see cref="NodeId.Null"/> when the item publishes none.
|
|
/// </summary>
|
|
/// <param name="machineryItem">The machine or component to inspect.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public ValueTask<NodeId> ResolveConsumptionAsync(
|
|
NodeId machineryItem,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (m_options is not null &&
|
|
!TryGetNamespaceIndex(Namespaces.Machinery, out _))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return new ValueTask<NodeId>(NodeId.Null);
|
|
}
|
|
ushort machineryNamespaceIndex = NamespaceIndexOf(
|
|
Namespaces.Machinery);
|
|
return ResolvePathAsync(
|
|
machineryItem,
|
|
cancellationToken,
|
|
new QualifiedName(MachineryBrowseNames.Monitoring, machineryNamespaceIndex),
|
|
new QualifiedName(MachineryBrowseNames.Consumption, machineryNamespaceIndex));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates the OPC 40001-4 resource folders a machinery item
|
|
/// publishes — one per energy resource it meters.
|
|
/// </summary>
|
|
/// <param name="machineryItem">The machine or component to inspect.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public async IAsyncEnumerable<MachineEntry> EnumerateEnergyResourcesAsync(
|
|
NodeId machineryItem,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
NodeId consumption = await ResolveConsumptionAsync(machineryItem, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await foreach (MachineEntry entry in EnumerateChildObjectsAsync(
|
|
consumption,
|
|
cancellationToken).ConfigureAwait(false))
|
|
{
|
|
yield return entry;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates every metering point of an OPC 40001-4 resource folder:
|
|
/// <c>Main</c> and the further points the resource publishes next to
|
|
/// it.
|
|
/// </summary>
|
|
/// <param name="resource">The resource folder to inspect.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public IAsyncEnumerable<MachineEntry> EnumerateMeteringPointsAsync(
|
|
NodeId resource,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return EnumerateChildObjectsAsync(resource, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates the sub-meters of a metering point — the points it
|
|
/// <c>Contains</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// OPC 40001-4 §8.1 runs <c>Contains</c> from a metering point to a
|
|
/// point whose measurements are part of it, typically from <c>Main</c>
|
|
/// to the points below the same resource. The reference is followed
|
|
/// forward and with its subtypes, so a vendor refinement of it is
|
|
/// found too; a point that contains nothing yields nothing.
|
|
/// </remarks>
|
|
/// <param name="meteringPoint">The containing metering point.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public IAsyncEnumerable<MachineEntry> EnumerateSubMetersAsync(
|
|
NodeId meteringPoint,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (m_options is not null &&
|
|
!TryGetNamespaceIndex(
|
|
Energy.Namespaces.MachineryEnergy, out _))
|
|
{
|
|
return EnumerateChildObjectsAsync(NodeId.Null, cancellationToken);
|
|
}
|
|
var contains = new NodeId(
|
|
Energy.ReferenceTypes.Contains,
|
|
NamespaceIndexOf(Energy.Namespaces.MachineryEnergy));
|
|
return BrowseChildrenAsync(
|
|
meteringPoint,
|
|
NodeClass.Object,
|
|
contains,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads every sub-meter a metering point <c>Contains</c>, the same
|
|
/// way <see cref="ReadMeteringPointAsync"/> reads one point.
|
|
/// </summary>
|
|
/// <param name="meteringPoint">The containing metering point.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public async IAsyncEnumerable<MachineryMeteringPoint> ReadSubMetersAsync(
|
|
NodeId meteringPoint,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
await foreach (MachineEntry subMeter in EnumerateSubMetersAsync(
|
|
meteringPoint,
|
|
cancellationToken).ConfigureAwait(false))
|
|
{
|
|
MachineryMeteringPoint? point = await ReadMeteringPointAsync(
|
|
subMeter.NodeId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (point != null)
|
|
{
|
|
yield return point;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the <c>Main</c> metering point of a resource, or
|
|
/// <see langword="null"/> when the resource publishes none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// OPC 40001-4 requires a <c>Main</c> point per resource folder, so a
|
|
/// null here means the server is not conformant rather than that the
|
|
/// feature is absent.
|
|
/// </remarks>
|
|
/// <param name="resource">The resource folder to read.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
/// <exception cref="MachineryReadException"></exception>
|
|
public async ValueTask<MachineryMeteringPoint?> ReadMainMeteringPointAsync(
|
|
NodeId resource,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (m_options is not null &&
|
|
!TryGetNamespaceIndex(
|
|
Energy.Namespaces.MachineryEnergy, out _))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return null;
|
|
}
|
|
ushort energyNamespaceIndex = NamespaceIndexOf(
|
|
Energy.Namespaces.MachineryEnergy);
|
|
NodeId main = await ResolveChildAsync(
|
|
resource,
|
|
new QualifiedName(
|
|
Energy.BrowseNames.Main,
|
|
energyNamespaceIndex),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (m_options is not null && main.IsNull)
|
|
{
|
|
throw ReadFailure(
|
|
StatusCodes.BadNoMatch,
|
|
UnresolvedField(
|
|
resource,
|
|
[new QualifiedName(Energy.BrowseNames.Main, energyNamespaceIndex)],
|
|
StatusCodes.BadNoMatch,
|
|
isAbsent: true),
|
|
"An energy resource must publish its Main metering point.");
|
|
}
|
|
return main.IsNull
|
|
? null
|
|
: await ReadMeteringPointAsync(main, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads one OPC 34100 metering point: its application tag and every
|
|
/// measurement value below it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each measurement carries the NodeId of its variable, so a client
|
|
/// that wants live values subscribes to it without browsing the
|
|
/// point again. A measurement whose value cannot be read is left out.
|
|
/// </remarks>
|
|
/// <param name="meteringPoint">The metering point to read.</param>
|
|
/// <param name="cancellationToken">Cancels the operation.</param>
|
|
public async ValueTask<MachineryMeteringPoint?> ReadMeteringPointAsync(
|
|
NodeId meteringPoint,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (meteringPoint.IsNull)
|
|
{
|
|
return null;
|
|
}
|
|
if (m_options is not null &&
|
|
!TryGetNamespaceIndex(ECM.Namespaces.ECM, out _) &&
|
|
!TryGetNamespaceIndex(Energy.Namespaces.MachineryEnergy, out _))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return null;
|
|
}
|
|
ushort ecmNamespaceIndex = NamespaceIndexOf(ECM.Namespaces.ECM);
|
|
var applicationTagName = new QualifiedName(
|
|
EcmBrowseNames.ApplicationTag,
|
|
ecmNamespaceIndex);
|
|
|
|
ReadValueSet values = await ReadChildValuesAsync(
|
|
meteringPoint,
|
|
[applicationTagName],
|
|
ReferenceTypeIds.HierarchicalReferences,
|
|
cancellationToken).ConfigureAwait(false);
|
|
values.RequirePresent(applicationTagName);
|
|
string? applicationTag = AsString(values, applicationTagName);
|
|
|
|
var variables = new List<MachineEntry>();
|
|
var measurements = new List<MachineryMeasurementValue>();
|
|
ArrayOf<MachineryReadValue> measurementValues = [];
|
|
await foreach (MachineEntry child in EnumerateChildVariablesAsync(
|
|
meteringPoint,
|
|
cancellationToken).ConfigureAwait(false))
|
|
{
|
|
if (child.BrowseName != applicationTagName)
|
|
{
|
|
variables.Add(child);
|
|
}
|
|
}
|
|
if (variables.Count > 0)
|
|
{
|
|
if (m_options is not null)
|
|
{
|
|
var sources = new MachineryReadValue[variables.Count];
|
|
for (int ii = 0; ii < sources.Length; ii++)
|
|
{
|
|
sources[ii] = new MachineryReadValue(
|
|
variables[ii].NodeId,
|
|
meteringPoint,
|
|
[variables[ii].BrowseName],
|
|
DataValue.FromStatusCode(StatusCodes.Good));
|
|
}
|
|
measurementValues = await ReadStrictSourcesAsync(sources.ToArrayOf(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ArrayOf<(NodeId DataType, int ValueRank)> declarations =
|
|
await ReadMeasurementDeclarationsAsync(measurementValues, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
for (int ii = 0; ii < measurementValues.Count; ii++)
|
|
{
|
|
MachineryReadValue field = measurementValues[ii];
|
|
await ValidateMeasurementValueAsync(
|
|
field, declarations[ii], new ReadArrayBudget(m_options, field), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
QualifiedName name = field.BrowsePath[0];
|
|
bool startTime = name.Name == Ua.BrowseNames.StartTime &&
|
|
Session.NamespaceUris.GetString(name.NamespaceIndex) == IA.Namespaces.IA;
|
|
if (startTime)
|
|
{
|
|
RequireFieldType(field, HasType(field.Value.WrappedValue, BuiltInType.DateTime));
|
|
}
|
|
if (startTime ||
|
|
(!variables[ii].TypeDefinitionId.IsNull &&
|
|
await Session.NodeCache.IsTypeOfAsync(
|
|
variables[ii].TypeDefinitionId,
|
|
VariableTypeIds.PropertyType,
|
|
cancellationToken).ConfigureAwait(false)))
|
|
{
|
|
continue;
|
|
}
|
|
measurements.Add(new MachineryMeasurementValue(
|
|
field.BrowsePath[0], field.Value.WrappedValue)
|
|
{
|
|
NodeId = field.SourceNodeId,
|
|
Values = [field]
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// The browse already returned the NodeIds, so the values are
|
|
// read by them in one batched Read.
|
|
var reads = new ReadValueId[variables.Count];
|
|
for (int ii = 0; ii < variables.Count; ii++)
|
|
{
|
|
reads[ii] = new ReadValueId
|
|
{
|
|
NodeId = variables[ii].NodeId,
|
|
AttributeId = Attributes.Value
|
|
};
|
|
}
|
|
ReadResponse read = await Session.ReadAsync(
|
|
requestHeader: null,
|
|
maxAge: 0,
|
|
timestampsToReturn: TimestampsToReturn.Neither,
|
|
nodesToRead: reads.ToArrayOf(),
|
|
ct: cancellationToken).ConfigureAwait(false);
|
|
for (int ii = 0; ii < read.Results.Count && ii < variables.Count; ii++)
|
|
{
|
|
if (StatusCode.IsGood(read.Results[ii].StatusCode))
|
|
{
|
|
measurements.Add(new MachineryMeasurementValue(
|
|
variables[ii].BrowseName,
|
|
read.Results[ii].WrappedValue)
|
|
{
|
|
NodeId = variables[ii].NodeId
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return new MachineryMeteringPoint(
|
|
meteringPoint,
|
|
applicationTag,
|
|
measurements.ToArrayOf())
|
|
{
|
|
Values = CombineEvidence(values.Values, measurementValues)
|
|
};
|
|
}
|
|
|
|
private async ValueTask<ArrayOf<(NodeId DataType, int ValueRank)>> ReadMeasurementDeclarationsAsync(
|
|
ArrayOf<MachineryReadValue> fields,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var requests = new ReadValueId[fields.Count * 2];
|
|
for (int ii = 0; ii < fields.Count; ii++)
|
|
{
|
|
requests[2 * ii] = new ReadValueId
|
|
{
|
|
NodeId = fields[ii].SourceNodeId,
|
|
AttributeId = Attributes.DataType
|
|
};
|
|
requests[(2 * ii) + 1] = new ReadValueId
|
|
{
|
|
NodeId = fields[ii].SourceNodeId,
|
|
AttributeId = Attributes.ValueRank
|
|
};
|
|
}
|
|
ReadResponse response;
|
|
try
|
|
{
|
|
response = await Session.ReadAsync(
|
|
null, 0, TimestampsToReturn.Neither, requests.ToArrayOf(), cancellationToken)
|
|
.ConfigureAwait(false) ??
|
|
throw ReadFailure(StatusCodes.BadDecodingError, fields[0],
|
|
"The measurement declaration response is missing.");
|
|
}
|
|
catch (ServiceResultException exception) when (exception is not MachineryReadException)
|
|
{
|
|
throw ReadFailure(exception.StatusCode, fields[0], exception.Message, exception);
|
|
}
|
|
ValidateStrictResponse(
|
|
response.ResponseHeader, response.Results.Count, requests.Length, response.DiagnosticInfos.Count,
|
|
fields[0]);
|
|
var declarations = new (NodeId DataType, int ValueRank)[fields.Count];
|
|
for (int ii = 0; ii < fields.Count; ii++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
DataValue dataType = response.Results[2 * ii];
|
|
DataValue valueRank = response.Results[(2 * ii) + 1];
|
|
RequireGood(dataType.StatusCode, fields[ii]);
|
|
RequireGood(valueRank.StatusCode, fields[ii]);
|
|
if (!HasType(dataType.WrappedValue, BuiltInType.NodeId) ||
|
|
!dataType.WrappedValue.TryGetValue(out NodeId type) || type.IsNull ||
|
|
!HasType(valueRank.WrappedValue, BuiltInType.Int32) ||
|
|
!valueRank.WrappedValue.TryGetValue(out int rank) ||
|
|
rank < ValueRanks.ScalarOrOneDimension)
|
|
{
|
|
throw ReadFailure(StatusCodes.BadDecodingError, fields[ii],
|
|
"The measurement has an invalid DataType or ValueRank declaration.");
|
|
}
|
|
ValidateLocalNode(type, fields[ii]);
|
|
declarations[ii] = (type, rank);
|
|
}
|
|
return declarations.ToArrayOf();
|
|
}
|
|
|
|
private async ValueTask ValidateMeasurementValueAsync(
|
|
MachineryReadValue field,
|
|
(NodeId DataType, int ValueRank) declaration,
|
|
ReadArrayBudget budget,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Variant value = field.Value.WrappedValue;
|
|
ValidateVariantArrays(value, budget, cancellationToken);
|
|
if (value.IsNull)
|
|
{
|
|
return;
|
|
}
|
|
if (declaration.DataType.NamespaceIndex != 0 &&
|
|
!await Session.NodeCache.IsTypeOfAsync(
|
|
declaration.DataType, DataTypeIds.BaseDataType, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
throw ReadFailure(StatusCodes.BadTypeMismatch, field,
|
|
"The measurement DataType is not a supported data type.");
|
|
}
|
|
Variant checkedValue = value;
|
|
if (HasType(value, BuiltInType.ExtensionObject) &&
|
|
value.TryGetValue(out ExtensionObject encoded) && !encoded.IsNull)
|
|
{
|
|
IEncodeable? decoded;
|
|
try
|
|
{
|
|
if (!encoded.TryGetValue(out decoded, Session.MessageContext) || decoded is null)
|
|
{
|
|
throw ReadFailure(StatusCodes.BadDecodingError, field,
|
|
"The measurement structure could not be decoded.");
|
|
}
|
|
}
|
|
catch (ServiceResultException exception) when (exception is not MachineryReadException)
|
|
{
|
|
throw ReadFailure(exception.StatusCode, field, exception.Message, exception);
|
|
}
|
|
RequireFieldType(field, MatchesStructureIdentifier(encoded.TypeId, decoded));
|
|
checkedValue = Variant.FromStructure(decoded);
|
|
}
|
|
RequireFieldType(field, !TypeInfo.IsInstanceOfDataType(
|
|
checkedValue, declaration.DataType, declaration.ValueRank, Session.NamespaceUris, Session.TypeTree)
|
|
.IsUnknown);
|
|
}
|
|
}
|
|
}
|