Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs
T

295 lines
13 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Configuration options for OPC UA connections, parsed from connection details JSON.
/// All values have defaults matching the OPC Foundation SDK's typical settings.
/// </summary>
public record OpcUaConnectionOptions(
int SessionTimeoutMs = 60000,
int OperationTimeoutMs = 15000,
int PublishingIntervalMs = 1000,
int KeepAliveCount = 10,
int LifetimeCount = 30,
int MaxNotificationsPerPublish = 100,
int SamplingIntervalMs = 1000,
int QueueSize = 10,
string SecurityMode = "None",
// DataConnectionLayer-012: secure-by-default — untrusted server certificates are
// rejected unless an operator explicitly opts in per connection. Accepting any
// certificate defeats the Sign / SignAndEncrypt modes against a man-in-the-middle.
bool AutoAcceptUntrustedCerts = false,
bool DiscardOldest = true,
byte SubscriptionPriority = 0,
string SubscriptionDisplayName = "ScadaBridge",
string TimestampsToReturn = "Source",
OpcUaDeadbandOptions? Deadband = null,
OpcUaUserIdentityOptions? UserIdentity = null);
public record OpcUaDeadbandOptions(string Type, double Value);
public record OpcUaUserIdentityOptions(
string TokenType,
string Username,
string Password,
string CertificatePath,
string CertificatePassword);
/// <summary>
/// WP-7: Abstraction over OPC UA client library for testability.
/// The real implementation would wrap an OPC UA SDK (e.g., OPC Foundation .NET Standard Library).
/// </summary>
public interface IOpcUaClient : IAsyncDisposable
{
/// <summary>
/// Connects to an OPC UA server at the specified endpoint URL.
/// </summary>
/// <param name="endpointUrl">The OPC UA server endpoint URL.</param>
/// <param name="options">Connection options; if null, defaults are used.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Disconnects from the OPC UA server.
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task DisconnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a value indicating whether the client is currently connected to the server.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Creates a monitored item subscription for a node. Returns a subscription handle.
/// </summary>
/// <param name="nodeId">The OPC UA node ID to monitor.</param>
/// <param name="onValueChanged">Callback invoked when the monitored value changes, receiving nodeId, value, sourceTimestamp, and statusCode.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with a subscription handle string.</returns>
Task<string> CreateSubscriptionAsync(
string nodeId,
Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes a monitored item subscription by handle.
/// </summary>
/// <param name="subscriptionHandle">The subscription handle returned by CreateSubscriptionAsync.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to OPC UA Alarms &amp; Conditions events under
/// <paramref name="sourceNodeId"/> (or the Server object when null). On
/// (re)subscribe the adapter issues a ConditionRefresh and replays the
/// active conditions as Snapshot…SnapshotComplete transitions. Returns a
/// handle for <see cref="RemoveAlarmSubscriptionAsync"/>.
/// </summary>
/// <param name="sourceNodeId">OPC UA node ID to monitor; null subscribes to the Server object.</param>
/// <param name="conditionFilter">Optional OPC UA condition type filter; null subscribes to all conditions.</param>
/// <param name="onTransition">Callback invoked for each alarm transition received from the server.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that resolves to a subscription handle string for use with <see cref="RemoveAlarmSubscriptionAsync"/>.</returns>
Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId,
string? conditionFilter,
Action<NativeAlarmTransition> onTransition,
CancellationToken cancellationToken = default);
/// <summary>Removes an alarm-event subscription by handle.</summary>
/// <param name="subscriptionHandle">The handle returned by <see cref="CreateAlarmSubscriptionAsync"/>.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the current value of a node.
/// </summary>
/// <param name="nodeId">The OPC UA node ID to read.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with a tuple of (value, sourceTimestamp, statusCode).</returns>
Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default);
/// <summary>
/// Writes a value to a node.
/// </summary>
/// <param name="nodeId">The OPC UA node ID to write to.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with the OPC UA status code of the write operation.</returns>
Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
/// <summary>
/// Raised when the OPC UA session detects a keep-alive failure or the server
/// becomes unreachable. The adapter layer uses this to trigger reconnection.
/// </summary>
event Action? ConnectionLost;
/// <summary>
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
/// (or the server's ObjectsFolder when null). Pageable via the OPC UA
/// continuation-point mechanism: when a prior call returned a non-null
/// <see cref="BrowseChildrenResult.ContinuationToken"/>, pass it back via
/// <paramref name="continuationToken"/> to fetch the next page (BrowseNext);
/// a null/empty token starts a fresh browse. Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up.
/// </summary>
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page via BrowseNext; null/empty starts a fresh browse.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted).</returns>
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Factory for creating IOpcUaClient instances.
/// </summary>
public interface IOpcUaClientFactory
{
/// <summary>
/// Creates a new IOpcUaClient instance.
/// </summary>
/// <returns>A new IOpcUaClient instance.</returns>
IOpcUaClient Create();
}
/// <summary>
/// Default factory that creates stub OPC UA clients.
/// In production, this would create real OPC UA SDK client instances.
/// </summary>
public class DefaultOpcUaClientFactory : IOpcUaClientFactory
{
/// <inheritdoc />
public IOpcUaClient Create() => new StubOpcUaClient();
}
/// <summary>
/// Stub OPC UA client for development/testing. A real implementation would
/// wrap the OPC Foundation .NET Standard Library.
/// </summary>
internal class StubOpcUaClient : IOpcUaClient
{
/// <inheritdoc />
public bool IsConnected { get; private set; }
#pragma warning disable CS0067
/// <summary>Raised when the OPC UA session detects a keep-alive failure or the server becomes unreachable.</summary>
public event Action? ConnectionLost;
#pragma warning restore CS0067
/// <inheritdoc />
public Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
{
IsConnected = true;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DisconnectAsync(CancellationToken cancellationToken = default)
{
IsConnected = false;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
// Stub: no events. Real A&C subscription lives in RealOpcUaClient.
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
return Task.FromResult<(object?, DateTime, uint)>((null, DateTime.UtcNow, 0));
}
/// <inheritdoc />
public Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
return Task.FromResult<uint>(0); // Good status
}
/// <inheritdoc />
public Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
{
// Canned address-space tree so DCL browse/paging flows can be exercised
// without a live OPC UA server (T15):
// (root)
// ├─ Folder1 (Object, HasChildren=true)
// │ ├─ Tag1 (Variable) ← page 1
// │ └─ Tag2 (Variable) ← page 2 (continuation token "STUB_PAGE2")
// └─ Folder2 (Object, HasChildren=false, leaf)
// Folder1 fakes BrowseNext continuation paging so the continuation-token
// round-trip is testable; everything else is a single, exhausted page.
if (string.IsNullOrEmpty(parentNodeId))
{
return Task.FromResult(new BrowseChildrenResult(
new[]
{
new BrowseNode("Folder1", "Folder1", BrowseNodeClass.Object, HasChildren: true),
new BrowseNode("Folder2", "Folder2", BrowseNodeClass.Object, HasChildren: false),
},
Truncated: false));
}
if (parentNodeId == "Folder1")
{
// Page 2: the caller passed back the continuation token from page 1.
if (continuationToken == "STUB_PAGE2")
{
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag2", "Tag2", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: false));
}
// Page 1: return only Tag1 and a continuation token for the next page.
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag1", "Tag1", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: true,
ContinuationToken: "STUB_PAGE2"));
}
// Leaves (Folder2, Variable nodes, anything unknown) have no children.
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
}
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
/// <returns>A completed <see cref="ValueTask"/>.</returns>
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}