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,