Files
UA-.NETStandard/src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs
Klaus Landsdorf 5d26681ac3 Machinery client: return the NodeId of each metering point measurement (#4642)
# Description

`MachineryMeasurementValue` gets an additive init-only `NodeId`; the
positional constructor is unchanged. `ReadMeteringPointAsync` fills it,
and so do `ReadMainMeteringPointAsync` and `ReadSubMetersAsync`, which
use it.

The values are now read by the NodeIds the browse returned, in one
batched Read, instead of translating each browse name back to a NodeId
first. That is one round trip less, and value and NodeId come from the
same node.

`docs/Machinery.md` shows how to subscribe to the measurements.

Tests:

- New end-to-end test
`MeteringPointMeasurementsCarryTheNodeIdOfTheirVariableAsync` against
`MachineryIntegrationServer`, covering the main metering point and the
sub-meter. It reads `BrowseName` and `Value` behind every returned
NodeId. It fails on master.
- `Opc.Ua.Machinery.Tests` 165/165 on net10.0; all target frameworks
build with 0 warnings.
- Fork CI (full matrix incl. macOS) and CodeQL: green on `afa00065a`
(the same change on master `ce88955f7`) — [CI run
36826789991](https://github.com/biancode/UA-.NETStandard/actions/runs/36826789991)
(189 jobs), [CodeQL run
36826793071](https://github.com/biancode/UA-.NETStandard/actions/runs/36826793071).
Rebasing onto master with #4607 needed one conflict resolution in
`MachineryEndToEndTests.cs` (both new tests kept);
`Opc.Ua.Machinery.Tests` passes 165/165 on net10.0 locally afterwards
and CodeQL is green on that state ([run
36979020583](https://github.com/biancode/UA-.NETStandard/actions/runs/36979020583)).
The run on the current head is in progress: [CI run
37016200389](https://github.com/biancode/UA-.NETStandard/actions/runs/37016200389).

## Related Issues

- Fixes #4641

## Checklist

- [x] 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.
- [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. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-02 23:21:25 +02:00

255 lines
11 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)
{
ushort machineryNamespaceIndex = NamespaceIndexOf(
Opc.Ua.Machinery.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)
{
var contains = new NodeId(
Opc.Ua.Machinery.Energy.ReferenceTypes.Contains,
NamespaceIndexOf(Opc.Ua.Machinery.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>
public async ValueTask<MachineryMeteringPoint?> ReadMainMeteringPointAsync(
NodeId resource,
CancellationToken cancellationToken = default)
{
ushort energyNamespaceIndex = NamespaceIndexOf(
Opc.Ua.Machinery.Energy.Namespaces.MachineryEnergy);
NodeId main = await ResolveChildAsync(
resource,
new QualifiedName(
Opc.Ua.Machinery.Energy.BrowseNames.Main,
energyNamespaceIndex),
cancellationToken).ConfigureAwait(false);
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;
}
ushort ecmNamespaceIndex = NamespaceIndexOf(Opc.Ua.ECM.Namespaces.ECM);
var applicationTagName = new QualifiedName(
EcmBrowseNames.ApplicationTag,
ecmNamespaceIndex);
Dictionary<QualifiedName, Variant> values = await ReadChildValuesAsync(
meteringPoint,
[applicationTagName],
Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
cancellationToken).ConfigureAwait(false);
var variables = new List<MachineEntry>();
var measurements = new List<MachineryMeasurementValue>();
await foreach (MachineEntry child in EnumerateChildVariablesAsync(
meteringPoint,
cancellationToken).ConfigureAwait(false))
{
if (child.BrowseName != applicationTagName)
{
variables.Add(child);
}
}
if (variables.Count > 0)
{
// 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,
AsString(values, applicationTagName),
measurements.ToArrayOf());
}
}
}