mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
281596a24d
> Tracking issue #4576. #4577 and #4578 are merged, so this branch is rebased > onto `master` and contains only the Pumps commits. ## Summary Third of four stacked PRs. Adds OPC 40223 Pumps and Vacuum Pumps as a model package plus a server and a client SDK, over the Machinery and DI packages. | Package | Content | |---|---| | `Opc.Ua.Pumps` | OPC 40223 1.0.0, generated from the unmodified UA-Nodeset publication, plus the snapshot contracts both sides share (`PumpNameplate`, `PumpConfigurationData`, `PumpOperationalData`, `PumpMaintenanceData`, `PumpSupervisionStatus`, `PumpPortDescriptor`, `MultiPumpConfiguration`, gathered into `PumpSnapshot`) | | `Opc.Ua.Pumps.Server` | `AddPumps()` / `ConfigurePumps(...)`; `PumpsNodeManager : DiNodeManager` places each pump below `DeviceSet` and organizes it into the Machinery `Machines` folder; browse-name-addressed builders reach all ~3000 optional children without a method per child and create them through the model's own `CreateChild` | | `Opc.Ua.Pumps.Client` | discovery in both places (de-duplicated, vendor subtypes included), a reader per `PumpType` group, whole-pump snapshots; the open groups come back as a `PumpValueSet` keyed by the generated `BrowseNames` | The model requires Machinery 1.0.0 and DI 1.02.2 and resolves against the newer Machinery 1.04.1 and DI packages in this repository, since a `RequiredModel` version is a lower bound. The OPC 40223 NodeSet is large, so the model commit is too; the hand-written code starts at the server commit. `samples/DI/PumpDeviceIntegrationServer` is left as it is: its local NodeSet copies are also fixtures of `Opc.Ua.Types.Tests` and `Opc.Ua.WotCon.Tests`. Moving it onto these packages would be a separate follow-up (see #4576). ## Review follow-ups Separate commits after the docs/samples commit, each with a test that fails without it. - Copilot review: `PumpValue.AsStringArray` and `PumpValueSet.Names` return `ArrayOf<string>` with its null sentinel instead of `Nullable<ArrayOf<string>>`; the `PumpBuilder` uses `Variant.Null`. - Own review with the rubric in `.github/copilot-instructions.md`: pump creation is serialized from the duplicate check to the registration, so two concurrent calls with the same name no longer both add a pump; the client lets a failed browse call propagate instead of reading it as "no children"; the `ReadSupervisionAsync` docs say which read options apply. ## Testing - `tests/Opc.Ua.Pumps.Tests`: 73 tests on net8 and net10 and with the netstandard2.1 libraries; the client/server tests connect a real session over `opc.tcp` to a hosted `PumpsNodeManager`. - `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10 with SDK 10.0.401 and no warnings. - Changed-line coverage of this layer (`check-coverage.ps1`): 97.30 % (1550 of 1593 changed lines, against `master`). - Fork CI, full PR matrix incl. macOS: green, all 184 jobs ([run 36622243774](https://github.com/biancode/UA-.NETStandard/actions/runs/36622243774)); CodeQL: green ([run 36622248255](https://github.com/biancode/UA-.NETStandard/actions/runs/36622248255)). - Together with the other layer (Pumps and Scales merged on top of Machinery), built for every target framework and tested on net8, net10 and with the netstandard2.1 libraries, with the samples published. - Beyond the in-repo tests the implementation has been validated end to end against a separate, non-public OPC UA client and server. ## Reviewing Commits: model package → server → client → tests → docs and samples → review follow-ups.
277 lines
10 KiB
C#
277 lines
10 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;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.Subscriptions;
|
|
using Opc.Ua.Pumps;
|
|
using Opc.Ua.Pumps.Client;
|
|
using PumpsSample.Client;
|
|
using BrowseNames = Opc.Ua.Pumps.BrowseNames;
|
|
|
|
PumpsClientOptions options = PumpsClientOptions.Parse(args);
|
|
|
|
ITelemetryContext telemetry = DefaultTelemetry.Create(
|
|
logging => logging.AddConsole().SetMinimumLevel(LogLevel.Warning));
|
|
|
|
using var cancellation = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
cancellation.Cancel();
|
|
};
|
|
CancellationToken ct = cancellation.Token;
|
|
|
|
SampleSession connection = await SampleSession
|
|
.ConnectAsync(options, telemetry, ct)
|
|
.ConfigureAwait(false);
|
|
await using ConfiguredAsyncDisposable _ = connection.ConfigureAwait(false);
|
|
|
|
// session.Pumps(...) also teaches the session the OPC 40223 structures, so
|
|
// PhysicalAddress and the option sets decode into their generated types.
|
|
PumpsClient pumps = connection.Session.Pumps(telemetry);
|
|
if (!pumps.IsSupported)
|
|
{
|
|
Console.Error.WriteLine("The server does not publish the OPC 40223 Pumps namespace.");
|
|
return 1;
|
|
}
|
|
|
|
var watchList = new List<(string Label, NodeId NodeId)>();
|
|
NodeId firstPump = NodeId.Null;
|
|
|
|
// Discovery looks in the DI DeviceSet and the Machinery Machines folder and
|
|
// returns each pump once, vendor subtypes of PumpType included.
|
|
await foreach (PumpEntry entry in pumps.EnumeratePumpsAsync(ct).ConfigureAwait(false))
|
|
{
|
|
if (firstPump.IsNull)
|
|
{
|
|
firstPump = entry.NodeId;
|
|
}
|
|
|
|
// One call for everything the pump publishes. Each group also has its own
|
|
// Read…Async method for when you only need one of them.
|
|
PumpSnapshot pump = await pumps.ReadPumpAsync(entry.NodeId, cancellationToken: ct).ConfigureAwait(false);
|
|
PrintPump(pump);
|
|
|
|
string name = pump.BrowseName.Name ?? pump.NodeId.ToString();
|
|
AddWatch(watchList, $"{name} MassFlow", pump.Operational?.Measurements[BrowseNames.MassFlow]);
|
|
AddWatch(watchList, $"{name} PumpOperation", pump.Operational?.Signals[BrowseNames.PumpOperation]);
|
|
AddWatch(watchList, $"{name} Cavitation", pump.Supervision?.ProcessFluid[BrowseNames.Cavitation]);
|
|
// Vibration measurements are nested groups of Measurements, one per
|
|
// <Vibration> instance the server created.
|
|
foreach ((string point, PumpValueSet vibration) in pump.Operational?.Measurements.Groups ??
|
|
new Dictionary<string, PumpValueSet>())
|
|
{
|
|
AddWatch(watchList, $"{name} {point} vRMS", vibration[BrowseNames.OverallVibrationVelocityRMS]);
|
|
}
|
|
}
|
|
|
|
if (firstPump.IsNull)
|
|
{
|
|
Console.Error.WriteLine("The server publishes no pumps.");
|
|
return 1;
|
|
}
|
|
|
|
// Units and ranges never change, so a client polling on a cycle reads them
|
|
// once (the snapshot above did) and then polls values only: one browse and
|
|
// one read per group instead of two reads.
|
|
PumpValueSet polled = await pumps
|
|
.ReadMeasurementsAsync(firstPump, PumpReadOptions.ValuesOnly, ct)
|
|
.ConfigureAwait(false);
|
|
Console.WriteLine();
|
|
Console.WriteLine(
|
|
$"Polled {polled.Count} measurements with PumpReadOptions.ValuesOnly; " +
|
|
$"MassFlow = {Format(polled[BrowseNames.MassFlow])}");
|
|
|
|
if (options.WatchSeconds > 0 && watchList.Count > 0)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine(
|
|
$"Watching {watchList.Count} values for {options.WatchSeconds}s " +
|
|
"(the pumps swap duty every 90 s; cavitation appears once a minute) …");
|
|
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
window.CancelAfter(TimeSpan.FromSeconds(options.WatchSeconds));
|
|
|
|
var watchers = new List<Task>();
|
|
foreach ((string label, NodeId nodeId) in watchList)
|
|
{
|
|
watchers.Add(WatchAsync(connection, label, nodeId, window.Token));
|
|
}
|
|
await Task.WhenAll(watchers).ConfigureAwait(false);
|
|
}
|
|
|
|
return 0;
|
|
|
|
static void PrintPump(PumpSnapshot pump)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine($"Pump {pump.BrowseName.Name} ({pump.NodeId})");
|
|
|
|
PumpNameplate? nameplate = pump.Nameplate;
|
|
if (nameplate != null)
|
|
{
|
|
Console.WriteLine(
|
|
$" {nameplate.Manufacturer.Text} {nameplate.Model.Text}, serial {nameplate.SerialNumber}, " +
|
|
$"built {nameplate.YearOfConstruction}/{nameplate.MonthOfConstruction} in {nameplate.CountryOfOrigin}");
|
|
Console.WriteLine($" location: {nameplate.Location ?? "(not published)"}");
|
|
}
|
|
|
|
if (pump.Configuration is { } configuration)
|
|
{
|
|
PrintSet("Design", configuration.Design);
|
|
PrintSet("Implementation", configuration.Implementation);
|
|
PrintSet("SystemRequirements", configuration.SystemRequirements);
|
|
}
|
|
|
|
if (pump.Operational is { } operational)
|
|
{
|
|
PrintSet("Measurements", operational.Measurements);
|
|
PrintSet("Signals", operational.Signals);
|
|
if (operational.MultiPump is { } multiPump)
|
|
{
|
|
Console.WriteLine(
|
|
$" MultiPump: role {multiPump.PumpRole}, mode {multiPump.OperationMode}, " +
|
|
$"{multiPump.MaximumNumberOfPumpsInOperation} of {multiPump.NumberOfPumps} pumps in operation");
|
|
}
|
|
}
|
|
|
|
// ActiveSignals yields only the raised signals; empty is the healthy case.
|
|
if (pump.Supervision is { } supervision)
|
|
{
|
|
if (supervision.HasActiveSignals)
|
|
{
|
|
foreach ((string group, PumpValue signal) in supervision.ActiveSignals)
|
|
{
|
|
Console.WriteLine($" supervision: {group}/{signal.Name} RAISED");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(" supervision: no active signals");
|
|
}
|
|
}
|
|
|
|
if (pump.Maintenance is { } maintenance)
|
|
{
|
|
Console.WriteLine(
|
|
$" maintenance: {maintenance.StateOfTheItem}, {maintenance.MaintenanceLevel}, " +
|
|
$"failure {maintenance.HasFailure}, operating time " +
|
|
$"{Format(maintenance.General[BrowseNames.OperatingTime])}");
|
|
}
|
|
|
|
foreach (PumpPortDescriptor port in pump.Ports)
|
|
{
|
|
Console.WriteLine(
|
|
$" port {port.Name}: {port.Kind}, direction {port.Direction?.ToString() ?? "-"}, " +
|
|
$"{port.Category ?? "no category"}");
|
|
PrintSet($"{port.Name}/Measurements", port.Measurements);
|
|
}
|
|
|
|
foreach (PumpValue link in pump.Documentation)
|
|
{
|
|
Console.WriteLine($" document {link.Name}: {link.AsString()}");
|
|
}
|
|
}
|
|
|
|
static void PrintSet(string title, PumpValueSet values)
|
|
{
|
|
if (values.IsEmpty && values.Groups.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
Console.WriteLine($" {title}:");
|
|
foreach (PumpValue value in values)
|
|
{
|
|
string range = value.EuRange is { } euRange
|
|
? FormattableString.Invariant($" [{euRange.Low} … {euRange.High}]")
|
|
: string.Empty;
|
|
Console.WriteLine($" {value.Name,-34} {Format(value)}{range}");
|
|
}
|
|
foreach ((string name, PumpValueSet group) in values.Groups)
|
|
{
|
|
PrintSet($"{title}/{name}", group);
|
|
}
|
|
}
|
|
|
|
static string Format(PumpValue? value)
|
|
{
|
|
if (value == null)
|
|
{
|
|
return "(not published)";
|
|
}
|
|
string unit = value.EngineeringUnits?.DisplayName.Text is { Length: > 0 } symbol ? " " + symbol : string.Empty;
|
|
double? number = value.AsDouble();
|
|
return number.HasValue
|
|
? number.Value.ToString("G6", CultureInfo.InvariantCulture) + unit
|
|
: FormattableString.Invariant($"{value.Value}") + unit;
|
|
}
|
|
|
|
static void AddWatch(List<(string Label, NodeId NodeId)> watchList, string label, PumpValue? value)
|
|
{
|
|
// PumpValue.NodeId is the node that carries the reading; for a discrete
|
|
// signal object that is its DiscreteInputValue child, which is what a
|
|
// monitored item has to watch.
|
|
if (value != null && !value.NodeId.IsNull)
|
|
{
|
|
watchList.Add((label, value.NodeId));
|
|
}
|
|
}
|
|
|
|
static async Task WatchAsync(
|
|
SampleSession connection,
|
|
string label,
|
|
NodeId nodeId,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await foreach (DataValueChange change in connection.Streaming
|
|
.SubscribeDataChangesAsync(nodeId, ct: ct)
|
|
.ConfigureAwait(false))
|
|
{
|
|
DataValue value = change.Value;
|
|
string text = value.WrappedValue.TryGetValue(out double number)
|
|
? number.ToString("G6", CultureInfo.InvariantCulture)
|
|
: FormattableString.Invariant($"{value.WrappedValue}");
|
|
Console.WriteLine($" {DateTime.Now:HH:mm:ss} {label,-30} {text}");
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// The watch window elapsed or the user pressed Ctrl+C.
|
|
}
|
|
}
|