diff --git a/docs/Machinery.md b/docs/Machinery.md
index de001dfdbb..ed6d5668c4 100644
--- a/docs/Machinery.md
+++ b/docs/Machinery.md
@@ -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
diff --git a/src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs b/src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs
index c360ecf1a5..1095d111f2 100644
--- a/src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs
+++ b/src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs
@@ -175,6 +175,11 @@ namespace Opc.Ua.Machinery.Client
/// Reads one OPC 34100 metering point: its application tag and every
/// measurement value below it.
///
+ ///
+ /// 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.
+ ///
/// The metering point to read.
/// Cancels the operation.
public async ValueTask ReadMeteringPointAsync(
@@ -196,7 +201,7 @@ namespace Opc.Ua.Machinery.Client
Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
cancellationToken).ConfigureAwait(false);
- var names = new List();
+ var variables = new List();
var measurements = new List();
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 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
+ });
}
}
}
diff --git a/src/Opc.Ua.Machinery.Client/MachineryEntries.cs b/src/Opc.Ua.Machinery.Client/MachineryEntries.cs
index 4f88c20595..372907f9d9 100644
--- a/src/Opc.Ua.Machinery.Client/MachineryEntries.cs
+++ b/src/Opc.Ua.Machinery.Client/MachineryEntries.cs
@@ -216,7 +216,14 @@ namespace Opc.Ua.Machinery.Client
///
/// The measurement's browse name.
/// The reading.
- public sealed record MachineryMeasurementValue(QualifiedName BrowseName, Variant Value);
+ public sealed record MachineryMeasurementValue(QualifiedName BrowseName, Variant Value)
+ {
+ ///
+ /// The NodeId of the variable the reading was taken from - the node a
+ /// client subscribes to for live values.
+ ///
+ public NodeId NodeId { get; init; }
+ }
///
/// One OPC 34100 EnergyMeasurementType metering point below an
diff --git a/tests/Opc.Ua.Machinery.Tests/Integration/MachineryEndToEndTests.cs b/tests/Opc.Ua.Machinery.Tests/Integration/MachineryEndToEndTests.cs
index 79ed86eddb..b4fb4f8ff5 100644
--- a/tests/Opc.Ua.Machinery.Tests/Integration/MachineryEndToEndTests.cs
+++ b/tests/Opc.Ua.Machinery.Tests/Integration/MachineryEndToEndTests.cs
@@ -142,6 +142,52 @@ namespace Opc.Ua.Machinery.Tests.Integration
AssertJobListNotificationsAsync(machinery, jobProvider, session, ct));
}
+ ///
+ /// 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.
+ ///
+ [Test]
+ public Task MeteringPointMeasurementsCarryTheNodeIdOfTheirVariableAsync()
+ {
+ return RunAgainstPressAsync(async (machinery, _, _, session, ct) =>
+ {
+ var machines = new List();
+ await foreach (MachineEntry entry in machinery
+ .EnumerateMachinesAsync(ct)
+ .ConfigureAwait(false))
+ {
+ machines.Add(entry);
+ }
+ var resources = new List();
+ 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 { 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
+ /// Reads the browse name and the value behind every measurement's
+ /// NodeId: the node has to be the variable the reading came from.
+ ///
+ 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();
+ 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,