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.
This commit is contained in:
Klaus Landsdorf
2026-10-02 23:21:25 +02:00
committed by GitHub
parent fe6ba606c3
commit 5d26681ac3
4 changed files with 158 additions and 12 deletions
+17
View File
@@ -649,6 +649,23 @@ OPC 40001-2 does not fix where they hang — its examples put them on a sensor
component, on the machine, and below `Monitoring` — so every object below the
item whose type is `ProcessValueType` counts.
A metering point's measurements are read with one browse and one batched
`Read`. Each `MachineryMeasurementValue` carries the `NodeId` of the variable
its reading came from next to the value, so a client that wants live values
subscribes to those nodes without browsing the point again:
```csharp
MachineryMeteringPoint? main = await machinery.ReadMainMeteringPointAsync(resource, ct);
foreach (MachineryMeasurementValue measurement in main!.Measurements)
{
subscription.TryAddMonitoredItem(
measurement.BrowseName.Name!,
measurement.NodeId,
options => options with { SamplingInterval = TimeSpan.FromSeconds(1) },
out _);
}
```
State observation rides the generated `*StateMachineTypeClient` proxies, which
inherit `GetCurrentFiniteStateAsync`, `ObserveFiniteTransitionsAsync` and
`WaitForStateAsync` from `FiniteStateMachineTypeClient` — so a vendor subtype of
@@ -175,6 +175,11 @@ namespace Opc.Ua.Machinery.Client
/// 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(
@@ -196,7 +201,7 @@ namespace Opc.Ua.Machinery.Client
Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
cancellationToken).ConfigureAwait(false);
var names = new List<QualifiedName>();
var variables = new List<MachineEntry>();
var measurements = new List<MachineryMeasurementValue>();
await foreach (MachineEntry child in EnumerateChildVariablesAsync(
meteringPoint,
@@ -204,21 +209,38 @@ namespace Opc.Ua.Machinery.Client
{
if (child.BrowseName != applicationTagName)
{
names.Add(child.BrowseName);
variables.Add(child);
}
}
if (names.Count > 0)
if (variables.Count > 0)
{
Dictionary<QualifiedName, Variant> readings = await ReadChildValuesAsync(
meteringPoint,
names,
Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
cancellationToken).ConfigureAwait(false);
foreach (QualifiedName name in names)
// 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++)
{
if (readings.TryGetValue(name, out Variant reading))
reads[ii] = new ReadValueId
{
measurements.Add(new MachineryMeasurementValue(name, reading));
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
});
}
}
}
@@ -216,7 +216,14 @@ namespace Opc.Ua.Machinery.Client
/// </summary>
/// <param name="BrowseName">The measurement's browse name.</param>
/// <param name="Value">The reading.</param>
public sealed record MachineryMeasurementValue(QualifiedName BrowseName, Variant Value);
public sealed record MachineryMeasurementValue(QualifiedName BrowseName, Variant Value)
{
/// <summary>
/// The NodeId of the variable the reading was taken from - the node a
/// client subscribes to for live values.
/// </summary>
public NodeId NodeId { get; init; }
}
/// <summary>
/// One OPC 34100 <c>EnergyMeasurementType</c> metering point below an
@@ -142,6 +142,52 @@ namespace Opc.Ua.Machinery.Tests.Integration
AssertJobListNotificationsAsync(machinery, jobProvider, session, ct));
}
/// <summary>
/// Every measurement of a metering point carries the NodeId of the
/// variable its reading came from, so a client can subscribe to it
/// without browsing the point again.
/// </summary>
[Test]
public Task MeteringPointMeasurementsCarryTheNodeIdOfTheirVariableAsync()
{
return RunAgainstPressAsync(async (machinery, _, _, session, ct) =>
{
var machines = new List<MachineEntry>();
await foreach (MachineEntry entry in machinery
.EnumerateMachinesAsync(ct)
.ConfigureAwait(false))
{
machines.Add(entry);
}
var resources = new List<MachineEntry>();
await foreach (MachineEntry entry in machinery
.EnumerateEnergyResourcesAsync(machines[0].NodeId, ct)
.ConfigureAwait(false))
{
resources.Add(entry);
}
MachineEntry air = resources.Find(entry => entry.BrowseName.Name == "CompressedAir")!;
MachineryMeteringPoint? main = await machinery
.ReadMainMeteringPointAsync(air.NodeId, ct)
.ConfigureAwait(false);
Assert.That(main, Is.Not.Null);
var points = new List<MachineryMeteringPoint> { main! };
await foreach (MachineryMeteringPoint subMeter in machinery
.ReadSubMetersAsync(main!.NodeId, ct)
.ConfigureAwait(false))
{
points.Add(subMeter);
}
Assert.That(points, Has.Count.EqualTo(2), "Main and the ClampingCylinders sub-meter");
foreach (MachineryMeteringPoint point in points)
{
await AssertMeasurementNodeIdsAsync(session, point, ct).ConfigureAwait(false);
}
});
}
private static async Task RunAgainstPressAsync(
Func<MachineryClient,
PressConfigurator,
@@ -658,6 +704,60 @@ namespace Opc.Ua.Machinery.Tests.Integration
Assert.That(nested, Is.Empty);
}
/// <summary>
/// Reads the browse name and the value behind every measurement's
/// NodeId: the node has to be the variable the reading came from.
/// </summary>
private static async Task AssertMeasurementNodeIdsAsync(
ClientSession session,
MachineryMeteringPoint point,
CancellationToken ct)
{
MachineryMeasurementValue[] measurements = [.. point.Measurements];
Assert.That(measurements, Is.Not.Empty, point.ApplicationTag);
Assert.That(
measurements.All(measurement => !measurement.NodeId.IsNull),
Is.True,
point.ApplicationTag + ": every measurement has a NodeId");
Assert.That(
measurements.Select(measurement => measurement.NodeId),
Is.Unique,
point.ApplicationTag + ": every measurement has its own NodeId");
var reads = new List<ReadValueId>();
foreach (MachineryMeasurementValue measurement in measurements)
{
reads.Add(new ReadValueId { NodeId = measurement.NodeId, AttributeId = Attributes.BrowseName });
reads.Add(new ReadValueId { NodeId = measurement.NodeId, AttributeId = Attributes.Value });
}
ReadResponse response = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: reads.ToArray().ToArrayOf(),
ct: ct).ConfigureAwait(false);
Assert.That(response.Results.Count, Is.EqualTo(reads.Count));
Assert.Multiple(() =>
{
for (int ii = 0; ii < measurements.Length; ii++)
{
MachineryMeasurementValue measurement = measurements[ii];
Assert.That(
response.Results[2 * ii].WrappedValue.TryGetValue(out QualifiedName browseName),
Is.True);
Assert.That(
browseName,
Is.EqualTo(measurement.BrowseName),
"the NodeId is the measurement's variable");
Assert.That(
response.Results[(2 * ii) + 1].WrappedValue,
Is.EqualTo(measurement.Value),
measurement.BrowseName.Name + ": the reading came from that variable");
}
});
}
private static async Task AssertJobsAsync(
MachineryClient machinery,
NodeId machine,