Files
romanett f061c2d7da Merge Master to Release/2.0 (#4676)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Klaus Landsdorf <kl@iniationware.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
2026-10-06 09:35:42 +00:00

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.
}
}