refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,189 @@
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>
/// 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>
/// 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
/// <inheritdoc />
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<(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 ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,359 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// WP-7: OPC UA adapter implementing IDataConnection.
/// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction.
///
/// OPC UA mapping:
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature")
/// - Subscribe → MonitoredItem with DataChangeNotification
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection : IDataConnection
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
private IOpcUaClient? _client;
private string _endpointUrl = string.Empty;
private ConnectionHealth _status = ConnectionHealth.Disconnected;
// DataConnectionLayer-019: the previous _subscriptionHandles Dictionary was
// dead state — written by SubscribeAsync, removed by UnsubscribeAsync, but
// never read anywhere. Plain Dictionary writes from thread-pool continuations
// after await are racy (concurrent resize is undefined: InvalidOperationException,
// bucket corruption, or silently lost entries). Bookkeeping for subscriptions
// lives at two genuine layers: RealOpcUaClient._monitoredItems/_callbacks
// (already ConcurrentDictionary per DCL-003) at the device adapter, and
// DataConnectionActor._subscriptionIds at the actor — both authoritative.
// The adapter has no need for a third copy; the field is removed rather than
// converted to ConcurrentDictionary because there is no reader.
private StaleTagMonitor? _staleMonitor;
private string? _heartbeatSubscriptionId;
/// <summary>
/// Initializes a new instance of <see cref="OpcUaDataConnection"/>.
/// </summary>
/// <param name="clientFactory">Factory used to create OPC UA client instances.</param>
/// <param name="logger">Logger instance.</param>
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
{
_clientFactory = clientFactory;
_logger = logger;
}
// DataConnectionLayer-013: an int flag toggled with Interlocked.Exchange so the
// "only the first caller fires Disconnected" guard in RaiseDisconnected is genuinely
// atomic. A plain volatile bool gives visibility but not atomicity — two threads
// (e.g. the keep-alive thread and a ReadAsync failure path) could both observe it
// false and both raise the event. 0 = not fired, 1 = fired.
private int _disconnectFired;
/// <inheritdoc />
public ConnectionHealth Status => _status;
/// <summary>Raised when the OPC UA connection is lost.</summary>
public event Action? Disconnected;
/// <inheritdoc />
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);
_endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
? "opc.tcp://localhost:4840"
: config.EndpointUrl;
var options = new OpcUaConnectionOptions(
SessionTimeoutMs: config.SessionTimeoutMs,
OperationTimeoutMs: config.OperationTimeoutMs,
PublishingIntervalMs: config.PublishingIntervalMs,
KeepAliveCount: config.KeepAliveCount,
LifetimeCount: config.LifetimeCount,
MaxNotificationsPerPublish: config.MaxNotificationsPerPublish,
SamplingIntervalMs: config.SamplingIntervalMs,
QueueSize: config.QueueSize,
SecurityMode: config.SecurityMode.ToString(),
AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts,
DiscardOldest: config.DiscardOldest,
SubscriptionPriority: config.SubscriptionPriority,
SubscriptionDisplayName: config.SubscriptionDisplayName,
TimestampsToReturn: config.TimestampsToReturn.ToString(),
Deadband: config.Deadband is { } db
? new OpcUaDeadbandOptions(db.Type.ToString(), db.Value)
: null,
UserIdentity: config.UserIdentity is { } ui
? new OpcUaUserIdentityOptions(
ui.TokenType.ToString(), ui.Username, ui.Password,
ui.CertificatePath, ui.CertificatePassword)
: null);
_status = ConnectionHealth.Connecting;
_client = _clientFactory.Create();
_client.ConnectionLost += OnClientConnectionLost;
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
_status = ConnectionHealth.Connected;
Interlocked.Exchange(ref _disconnectFired, 0);
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
}
private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken)
{
if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath))
return;
_staleMonitor?.Dispose();
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
_staleMonitor.Stale += () =>
{
_logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s",
heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
RaiseDisconnected();
};
try
{
_heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
(_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
_staleMonitor.Start();
_logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence",
heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active",
heartbeat.TagPath);
_staleMonitor.Dispose();
_staleMonitor = null;
}
}
private void OnClientConnectionLost()
{
RaiseDisconnected();
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
StopHeartbeatMonitor();
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
await _client.DisconnectAsync(cancellationToken);
_status = ConnectionHealth.Disconnected;
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
}
}
/// <inheritdoc />
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
{
EnsureConnected();
// DataConnectionLayer-019: subscriptionId is returned directly to the
// caller (DataConnectionActor stores it in _subscriptionIds). No local
// bookkeeping is kept here — see the field-deletion note above.
return await _client!.CreateSubscriptionAsync(
tagPath,
(nodeId, value, timestamp, statusCode) =>
{
var quality = MapStatusCode(statusCode);
callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
},
cancellationToken);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_client != null)
{
await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken);
}
}
/// <inheritdoc />
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
{
EnsureConnected();
try
{
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
var quality = MapStatusCode(statusCode);
if (quality == QualityCode.Bad)
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "OPC UA read failed for {TagPath} — connection may be lost", tagPath);
RaiseDisconnected();
throw;
}
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
{
// DataConnectionLayer-007: a single failing tag must not abort the whole batch.
// ReadAsync re-throws non-cancellation exceptions; catch them per tag and record
// a failed ReadResult so the caller receives a complete result map for every
// requested tag (the ReadResult shape already carries per-tag Success/error).
var results = new Dictionary<string, ReadResult>();
foreach (var tagPath in tagPaths)
{
try
{
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
results[tagPath] = new ReadResult(false, null, ex.Message);
}
}
return results;
}
/// <inheritdoc />
public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
{
EnsureConnected();
var statusCode = await _client!.WriteValueAsync(tagPath, value, cancellationToken);
if (statusCode != 0)
return new WriteResult(false, $"OPC UA write failed with status: 0x{statusCode:X8}");
return new WriteResult(true, null);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
{
// DataConnectionLayer-017: a mid-batch fault must not abort the whole batch.
// WriteAsync calls EnsureConnected(), which throws InvalidOperationException when
// the connection drops partway through; catch per-tag exceptions and record a
// failed WriteResult so the caller (including WriteBatchAndWaitAsync) receives a
// complete result map. OperationCanceledException is still propagated so a
// cancelled batch aborts as a whole — mirrors the DCL-007 fix for ReadBatchAsync.
var results = new Dictionary<string, WriteResult>();
foreach (var (tagPath, value) in values)
{
try
{
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
results[tagPath] = new WriteResult(false, ex.Message);
}
}
return results;
}
/// <inheritdoc />
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
string responsePath, object? responseValue, TimeSpan timeout,
CancellationToken cancellationToken = default)
{
// Write all values including the flag
var allValues = new Dictionary<string, object?>(values) { [flagPath] = flagValue };
var writeResults = await WriteBatchAsync(allValues, cancellationToken);
if (writeResults.Values.Any(r => !r.Success))
return false;
// Poll for response value within timeout
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
var readResult = await ReadAsync(responsePath, cancellationToken);
if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue))
return true;
await Task.Delay(100, cancellationToken);
}
return false;
}
private void StopHeartbeatMonitor()
{
_staleMonitor?.Dispose();
_staleMonitor = null;
_heartbeatSubscriptionId = null;
}
/// <summary>
/// Asynchronously disposes the OPC UA connection, releasing the client and stopping the heartbeat monitor.
/// </summary>
public async ValueTask DisposeAsync()
{
StopHeartbeatMonitor();
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
await _client.DisposeAsync();
_client = null;
}
_status = ConnectionHealth.Disconnected;
}
private void EnsureConnected()
{
if (_client == null || !_client.IsConnected)
throw new InvalidOperationException("OPC UA client is not connected.");
}
/// <summary>
/// Marks the connection as disconnected and fires the Disconnected event once.
/// Thread-safe: the firing guard is an atomic compare-and-set
/// (<see cref="Interlocked.Exchange(ref int, int)"/>), so when several threads race
/// here — e.g. the keep-alive thread via <see cref="OnClientConnectionLost"/> and a
/// <c>ReadAsync</c> failure path — exactly one of them observes the 0→1 transition
/// and invokes <see cref="Disconnected"/>.
/// </summary>
private void RaiseDisconnected()
{
if (Interlocked.Exchange(ref _disconnectFired, 1) != 0) return;
_status = ConnectionHealth.Disconnected;
_logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl);
Disconnected?.Invoke();
}
/// <summary>
/// Maps OPC UA StatusCode to QualityCode.
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
/// </summary>
private static QualityCode MapStatusCode(uint statusCode)
{
if (statusCode == 0) return QualityCode.Good;
if ((statusCode & 0x80000000) != 0) return QualityCode.Bad;
return QualityCode.Uncertain;
}
}
@@ -0,0 +1,370 @@
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library.
/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions.
/// </summary>
public class RealOpcUaClient : IOpcUaClient
{
private ISession? _session;
private Subscription? _subscription;
// DataConnectionLayer-003: these maps are read from the OPC Foundation SDK's
// internal publish threads (the MonitoredItem.Notification handler reads
// _callbacks) concurrently with subscribe/disconnect mutations that run on
// thread-pool threads. Plain Dictionary access during a concurrent resize or
// Clear() is undefined behaviour, so they must be ConcurrentDictionary.
private readonly ConcurrentDictionary<string, MonitoredItem> _monitoredItems = new();
private readonly ConcurrentDictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
// DataConnectionLayer-013: int flag toggled with Interlocked.Exchange so the
// once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible.
// 0 = not fired, 1 = fired.
private int _connectionLostFired;
private OpcUaConnectionOptions _options = new();
private readonly OpcUaGlobalOptions _globalOptions;
private readonly ILogger<RealOpcUaClient> _logger;
/// <summary>
/// Initializes a new instance of the RealOpcUaClient class.
/// </summary>
/// <param name="globalOptions">Global OPC UA options, or null to use defaults.</param>
/// <param name="logger">Logger instance, or null to use a null logger.</param>
public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null, ILogger<RealOpcUaClient>? logger = null)
{
_globalOptions = globalOptions ?? new OpcUaGlobalOptions();
_logger = logger ?? NullLogger<RealOpcUaClient>.Instance;
}
/// <inheritdoc />
public bool IsConnected => _session?.Connected ?? false;
/// <summary>Raised when the OPC UA connection is lost.</summary>
public event Action? ConnectionLost;
/// <inheritdoc />
public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
{
var opts = options ?? new OpcUaConnectionOptions();
var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch
{
"SIGN" => MessageSecurityMode.Sign,
"SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
? "ScadaBridge-DCL"
: _globalOptions.ApplicationName,
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
};
await appConfig.ValidateAsync(ApplicationType.Client);
if (opts.AutoAcceptUntrustedCerts)
{
// DataConnectionLayer-012: this accepts ANY server certificate, defeating
// certificate trust enforcement. Surface a prominent warning so an operator
// who has opted in is aware of the man-in-the-middle exposure on the link.
_logger.LogWarning(
"OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " +
"server certificate is accepted unconditionally. This defeats Sign / " +
"SignAndEncrypt protection against a man-in-the-middle.", endpointUrl);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
}
// Discover endpoints from the server, pick the preferred security mode
EndpointDescription? endpoint;
try
{
#pragma warning disable CS0618
using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
#pragma warning restore CS0618
#pragma warning disable CS0618
var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
endpoint = endpoints
.Where(e => e.SecurityMode == preferredSecurityMode)
.FirstOrDefault() ?? endpoints.FirstOrDefault();
}
catch
{
// Fallback: construct endpoint description manually
endpoint = new EndpointDescription(endpointUrl);
}
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
var userIdentity = BuildUserIdentity(opts.UserIdentity);
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
// Detect server going offline via keep-alive failures
Interlocked.Exchange(ref _connectionLostFired, 0);
_session.KeepAlive += OnSessionKeepAlive;
// Store options for monitored item creation
_options = opts;
// Create a default subscription for all monitored items
_subscription = new Subscription(_session.DefaultSubscription)
{
DisplayName = opts.SubscriptionDisplayName,
Priority = opts.SubscriptionPriority,
PublishingEnabled = true,
PublishingInterval = opts.PublishingIntervalMs,
KeepAliveCount = (uint)opts.KeepAliveCount,
LifetimeCount = (uint)opts.LifetimeCount,
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
};
_session.AddSubscription(_subscription);
await _subscription.CreateAsync(cancellationToken);
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_subscription != null)
{
await _subscription.DeleteAsync(true);
_subscription = null;
}
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
await _session.CloseAsync(cancellationToken);
_session = null;
}
_monitoredItems.Clear();
_callbacks.Clear();
}
/// <inheritdoc />
public async Task<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = _options.DiscardOldest,
Filter = BuildDataChangeFilter(_options.Deadband)
};
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value?.Value;
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var statusCode = notification.Value?.StatusCode.Code ?? 0;
if (_callbacks.TryGetValue(handle, out var cb))
{
cb(nodeId, value, timestamp, statusCode);
}
}
};
_subscription.AddItem(monitoredItem);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems[handle] = monitoredItem;
return handle;
}
/// <inheritdoc />
public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems.TryRemove(subscriptionHandle, out _);
_callbacks.TryRemove(subscriptionHandle, out _);
}
}
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var readValue = new ReadValueId
{
NodeId = nodeId,
AttributeId = Attributes.Value
};
var response = await _session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn),
new ReadValueIdCollection { readValue }, cancellationToken);
var result = response.Results[0];
return (result.Value, result.SourceTimestamp, result.StatusCode.Code);
}
/// <inheritdoc />
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var writeValue = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var response = await _session.WriteAsync(
null, new WriteValueCollection { writeValue }, cancellationToken);
return response.Results[0].Code;
}
/// <summary>
/// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
/// When CurrentState is bad, the server is unreachable. The once-only guard is an
/// atomic compare-and-set, so a burst of failed keep-alives raises
/// <see cref="ConnectionLost"/> exactly once.
/// </summary>
private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
{
if (ServiceResult.IsBad(e.Status))
{
if (Interlocked.Exchange(ref _connectionLostFired, 1) != 0) return;
ConnectionLost?.Invoke();
}
}
/// <summary>
/// Asynchronously disposes the OPC UA client, disconnecting from the server.
/// </summary>
/// <returns>A task representing the asynchronous disposal.</returns>
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options)
{
if (options is null) return null;
return options.TokenType.ToUpperInvariant() switch
{
"USERNAMEPASSWORD" => new UserIdentity(
options.Username,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")),
"X509CERTIFICATE" => new UserIdentity(
X509CertificateLoader.LoadPkcs12FromFile(
options.CertificatePath, options.CertificatePassword)),
_ => null
};
}
private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband)
{
if (deadband is null) return null;
var deadbandType = deadband.Type.ToUpperInvariant() switch
{
"PERCENT" => DeadbandType.Percent,
_ => DeadbandType.Absolute
};
return new DataChangeFilter
{
Trigger = DataChangeTrigger.StatusValue,
DeadbandType = (uint)deadbandType,
DeadbandValue = deadband.Value
};
}
private static TimestampsToReturn MapTimestampsToReturn(string mode) =>
mode.ToUpperInvariant() switch
{
"SERVER" => TimestampsToReturn.Server,
"BOTH" => TimestampsToReturn.Both,
_ => TimestampsToReturn.Source
};
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
string.IsNullOrWhiteSpace(configured)
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
: configured;
}
/// <summary>
/// Factory that creates real OPC UA client instances using the OPC Foundation SDK.
/// </summary>
public class RealOpcUaClientFactory : IOpcUaClientFactory
{
private readonly OpcUaGlobalOptions _globalOptions;
// DataConnectionLayer-014: a real logger must be threaded through to every
// RealOpcUaClient this factory builds, otherwise the DCL-012 auto-accept-certificate
// warning emitted in RealOpcUaClient.ConnectAsync sinks into NullLogger and is never
// seen in production. The factory is constructed by DataConnectionFactory, which has
// an ILoggerFactory available.
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with default options.
/// </summary>
public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { }
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with global options.
/// </summary>
/// <param name="globalOptions">Global OPC UA options.</param>
public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions)
: this(globalOptions, NullLoggerFactory.Instance) { }
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with options and logger factory.
/// </summary>
/// <param name="globalOptions">Global OPC UA options.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions, ILoggerFactory loggerFactory)
{
_globalOptions = globalOptions;
_loggerFactory = loggerFactory;
}
/// <inheritdoc />
public IOpcUaClient Create() =>
new RealOpcUaClient(_globalOptions, _loggerFactory.CreateLogger<RealOpcUaClient>());
}