Files
Marc Schier 87012a147f Machinery: add bounded inspection and result transfers (#4804)
# 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.
2026-10-16 12:24:35 +02:00

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);
}
}
}