mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user