feat: add JoeAppEngine OPC UA nodes, fix DCL auto-reconnect and quality push
- Add JoeAppEngine folder to OPC UA nodes.json (BTCS, AlarmCntsBySeverity, Scheduler/ScanTime) - Fix DataConnectionActor: capture Self in PreStart for use from non-actor threads, preventing Self.Tell failure in Disconnected event handler - Implement InstanceActor.HandleConnectionQualityChanged to mark attributes Bad on disconnect - Fix LmxFakeProxy TagMapper to serialize arrays as JSON instead of "System.Int32[]" - Allow DataType and DataSourceReference updates in TemplateService.UpdateAttributeAsync - Update test_infra_opcua.md with JoeAppEngine documentation
This commit is contained in:
@@ -62,6 +62,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
|
||||
private readonly IDictionary<string, string> _connectionDetails;
|
||||
|
||||
/// <summary>
|
||||
/// Captured Self reference for use from non-actor threads (event handlers, callbacks).
|
||||
/// Akka.NET's Self property is only valid inside the actor's message loop.
|
||||
/// </summary>
|
||||
private IActorRef _self = null!;
|
||||
|
||||
public DataConnectionActor(
|
||||
string connectionName,
|
||||
IDataConnection adapter,
|
||||
@@ -79,13 +85,28 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
protected override void PreStart()
|
||||
{
|
||||
_log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName);
|
||||
|
||||
// Capture Self for use from non-actor threads (event handlers, callbacks).
|
||||
// Akka.NET's Self property is only valid inside the actor's message loop.
|
||||
_self = Self;
|
||||
|
||||
// Listen for unexpected adapter disconnections
|
||||
_adapter.Disconnected += OnAdapterDisconnected;
|
||||
|
||||
BecomeConnecting();
|
||||
}
|
||||
|
||||
private void OnAdapterDisconnected()
|
||||
{
|
||||
// Marshal the event onto the actor's message loop using captured _self reference.
|
||||
// This runs on a background thread (gRPC stream reader), so Self would throw.
|
||||
_self.Tell(new AdapterDisconnected());
|
||||
}
|
||||
|
||||
protected override void PostStop()
|
||||
{
|
||||
_log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName);
|
||||
// Clean up the adapter asynchronously
|
||||
_adapter.Disconnected -= OnAdapterDisconnected;
|
||||
_ = _adapter.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
@@ -276,7 +297,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
|
||||
private void HandleDisconnect()
|
||||
{
|
||||
_log.Warning("[{0}] Adapter reported disconnect", _connectionName);
|
||||
_log.Warning("[{0}] AdapterDisconnected message received — transitioning to Reconnecting", _connectionName);
|
||||
BecomeReconnecting();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
||||
Task<ILmxSubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, LmxVtq> onUpdate,
|
||||
Action? onStreamError = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ public interface ILmxProxyClient : IAsyncDisposable
|
||||
/// </summary>
|
||||
public interface ILmxProxyClientFactory
|
||||
{
|
||||
ILmxProxyClient Create(string host, int port, string? apiKey);
|
||||
ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,7 +57,7 @@ public interface ILmxProxyClientFactory
|
||||
/// </summary>
|
||||
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
public ILmxProxyClient Create(string host, int port, string? apiKey) => new StubLmxProxyClient();
|
||||
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false) => new StubLmxProxyClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,7 +94,7 @@ internal class StubLmxProxyClient : ILmxProxyClient
|
||||
public Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ILmxSubscription>(new StubLmxSubscription());
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
namespace ScadaLink.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",
|
||||
bool AutoAcceptUntrustedCerts = true);
|
||||
|
||||
/// <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
|
||||
{
|
||||
Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default);
|
||||
Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
bool IsConnected { get; }
|
||||
|
||||
@@ -24,6 +40,12 @@ public interface IOpcUaClient : IAsyncDisposable
|
||||
string nodeId, CancellationToken cancellationToken = default);
|
||||
|
||||
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>
|
||||
@@ -50,8 +72,11 @@ public class DefaultOpcUaClientFactory : IOpcUaClientFactory
|
||||
internal class StubOpcUaClient : IOpcUaClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
#pragma warning disable CS0067
|
||||
public event Action? ConnectionLost;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
|
||||
public Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -23,6 +23,7 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||
|
||||
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
||||
private volatile bool _disconnectFired;
|
||||
|
||||
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
}
|
||||
|
||||
public ConnectionHealth Status => _status;
|
||||
public event Action? Disconnected;
|
||||
|
||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -39,11 +41,15 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
_port = port;
|
||||
connectionDetails.TryGetValue("ApiKey", out var apiKey);
|
||||
|
||||
var samplingIntervalMs = connectionDetails.TryGetValue("SamplingIntervalMs", out var sampStr) && int.TryParse(sampStr, out var samp) ? samp : 0;
|
||||
var useTls = connectionDetails.TryGetValue("UseTls", out var tlsStr) && bool.TryParse(tlsStr, out var tls) && tls;
|
||||
|
||||
_status = ConnectionHealth.Connecting;
|
||||
_client = _clientFactory.Create(_host, _port, apiKey);
|
||||
_client = _clientFactory.Create(_host, _port, apiKey, samplingIntervalMs, useTls);
|
||||
|
||||
await _client.ConnectAsync(cancellationToken);
|
||||
_status = ConnectionHealth.Connected;
|
||||
_disconnectFired = false;
|
||||
|
||||
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
|
||||
}
|
||||
@@ -62,13 +68,22 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var vtq = await _client!.ReadAsync(tagPath, cancellationToken);
|
||||
var quality = MapQuality(vtq.Quality);
|
||||
var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero));
|
||||
try
|
||||
{
|
||||
var vtq = await _client!.ReadAsync(tagPath, cancellationToken);
|
||||
var quality = MapQuality(vtq.Quality);
|
||||
var tagValue = new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero));
|
||||
|
||||
return vtq.Quality == LmxQuality.Bad
|
||||
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
|
||||
: new ReadResult(true, tagValue, null);
|
||||
return vtq.Quality == LmxQuality.Bad
|
||||
? new ReadResult(false, tagValue, "LmxProxy read returned bad quality")
|
||||
: new ReadResult(true, tagValue, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "LmxProxy read failed for {TagPath} — connection may be lost", tagPath);
|
||||
RaiseDisconnected();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||
@@ -161,6 +176,11 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
var quality = MapQuality(vtq.Quality);
|
||||
callback(path, new TagValue(vtq.Value, quality, new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero)));
|
||||
},
|
||||
onStreamError: () =>
|
||||
{
|
||||
_logger.LogWarning("LmxProxy subscription stream ended unexpectedly for {TagPath}", tagPath);
|
||||
RaiseDisconnected();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var subscriptionId = Guid.NewGuid().ToString("N");
|
||||
@@ -199,6 +219,19 @@ public class LmxProxyDataConnection : IDataConnection
|
||||
throw new InvalidOperationException("LmxProxy client is not connected.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the connection as disconnected and fires the Disconnected event once.
|
||||
/// Thread-safe: only the first caller triggers the event.
|
||||
/// </summary>
|
||||
private void RaiseDisconnected()
|
||||
{
|
||||
if (_disconnectFired) return;
|
||||
_disconnectFired = true;
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
_logger.LogWarning("LmxProxy connection to {Host}:{Port} lost", _host, _port);
|
||||
Disconnected?.Invoke();
|
||||
}
|
||||
|
||||
private static QualityCode MapQuality(LmxQuality quality) => quality switch
|
||||
{
|
||||
LmxQuality.Good => QualityCode.Good,
|
||||
|
||||
@@ -33,29 +33,62 @@ public class OpcUaDataConnection : IDataConnection
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private volatile bool _disconnectFired;
|
||||
|
||||
public ConnectionHealth Status => _status;
|
||||
public event Action? Disconnected;
|
||||
|
||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Support both "endpoint" (from JSON config) and "EndpointUrl" (programmatic)
|
||||
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
|
||||
? url
|
||||
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
|
||||
? url2
|
||||
: "opc.tcp://localhost:4840";
|
||||
|
||||
var options = new OpcUaConnectionOptions(
|
||||
SessionTimeoutMs: ParseInt(connectionDetails, "SessionTimeoutMs", 60000),
|
||||
OperationTimeoutMs: ParseInt(connectionDetails, "OperationTimeoutMs", 15000),
|
||||
PublishingIntervalMs: ParseInt(connectionDetails, "PublishingIntervalMs", 1000),
|
||||
KeepAliveCount: ParseInt(connectionDetails, "KeepAliveCount", 10),
|
||||
LifetimeCount: ParseInt(connectionDetails, "LifetimeCount", 30),
|
||||
MaxNotificationsPerPublish: ParseInt(connectionDetails, "MaxNotificationsPerPublish", 100),
|
||||
SamplingIntervalMs: ParseInt(connectionDetails, "SamplingIntervalMs", 1000),
|
||||
QueueSize: ParseInt(connectionDetails, "QueueSize", 10),
|
||||
SecurityMode: connectionDetails.TryGetValue("SecurityMode", out var secMode) ? secMode : "None",
|
||||
AutoAcceptUntrustedCerts: ParseBool(connectionDetails, "AutoAcceptUntrustedCerts", true));
|
||||
|
||||
_status = ConnectionHealth.Connecting;
|
||||
|
||||
_client = _clientFactory.Create();
|
||||
await _client.ConnectAsync(_endpointUrl, cancellationToken);
|
||||
_client.ConnectionLost += OnClientConnectionLost;
|
||||
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
|
||||
|
||||
_status = ConnectionHealth.Connected;
|
||||
_disconnectFired = false;
|
||||
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
||||
}
|
||||
|
||||
internal static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
|
||||
{
|
||||
return d.TryGetValue(key, out var str) && int.TryParse(str, out var val) ? val : defaultValue;
|
||||
}
|
||||
|
||||
internal static bool ParseBool(IDictionary<string, string> d, string key, bool defaultValue)
|
||||
{
|
||||
return d.TryGetValue(key, out var str) && bool.TryParse(str, out var val) ? val : defaultValue;
|
||||
}
|
||||
|
||||
private void OnClientConnectionLost()
|
||||
{
|
||||
RaiseDisconnected();
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
_client.ConnectionLost -= OnClientConnectionLost;
|
||||
await _client.DisconnectAsync(cancellationToken);
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
|
||||
@@ -92,13 +125,22 @@ public class OpcUaDataConnection : IDataConnection
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
|
||||
var quality = MapStatusCode(statusCode);
|
||||
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}");
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||
@@ -163,6 +205,7 @@ public class OpcUaDataConnection : IDataConnection
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
_client.ConnectionLost -= OnClientConnectionLost;
|
||||
await _client.DisposeAsync();
|
||||
_client = null;
|
||||
}
|
||||
@@ -175,6 +218,19 @@ public class OpcUaDataConnection : IDataConnection
|
||||
throw new InvalidOperationException("OPC UA client is not connected.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the connection as disconnected and fires the Disconnected event once.
|
||||
/// Thread-safe: only the first caller triggers the event.
|
||||
/// </summary>
|
||||
private void RaiseDisconnected()
|
||||
{
|
||||
if (_disconnectFired) return;
|
||||
_disconnectFired = true;
|
||||
_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.
|
||||
|
||||
@@ -14,25 +14,31 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string? _apiKey;
|
||||
private readonly int _samplingIntervalMs;
|
||||
private readonly bool _useTls;
|
||||
private GrpcChannel? _channel;
|
||||
private ScadaService.ScadaServiceClient? _client;
|
||||
private string? _sessionId;
|
||||
private Metadata? _headers;
|
||||
|
||||
public RealLmxProxyClient(string host, int port, string? apiKey)
|
||||
public RealLmxProxyClient(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_apiKey = apiKey;
|
||||
_samplingIntervalMs = samplingIntervalMs;
|
||||
_useTls = useTls;
|
||||
}
|
||||
|
||||
public bool IsConnected => _client != null && !string.IsNullOrEmpty(_sessionId);
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
if (!_useTls)
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
|
||||
_channel = GrpcChannel.ForAddress($"http://{_host}:{_port}");
|
||||
var scheme = _useTls ? "https" : "http";
|
||||
_channel = GrpcChannel.ForAddress($"{scheme}://{_host}:{_port}");
|
||||
_client = new ScadaService.ScadaServiceClient(_channel);
|
||||
|
||||
_headers = new Metadata();
|
||||
@@ -111,13 +117,13 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
||||
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
|
||||
}
|
||||
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, CancellationToken cancellationToken = default)
|
||||
public Task<ILmxSubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, LmxVtq> onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var tags = addresses.ToList();
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = 0 };
|
||||
var request = new SubscribeRequest { SessionId = _sessionId!, SamplingMs = _samplingIntervalMs };
|
||||
request.Tags.AddRange(tags);
|
||||
|
||||
var call = _client!.Subscribe(request, _headers, cancellationToken: cts.Token);
|
||||
@@ -131,9 +137,18 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
||||
var msg = call.ResponseStream.Current;
|
||||
onUpdate(msg.Tag, ConvertVtq(msg));
|
||||
}
|
||||
// Stream ended normally (server closed) — treat as disconnect
|
||||
_sessionId = null;
|
||||
onStreamError?.Invoke();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { }
|
||||
catch (RpcException)
|
||||
{
|
||||
// gRPC error (server offline, network failure) — signal disconnect
|
||||
_sessionId = null;
|
||||
onStreamError?.Invoke();
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
return Task.FromResult<ILmxSubscription>(new CtsSubscription(cts));
|
||||
@@ -191,6 +206,6 @@ internal class RealLmxProxyClient : ILmxProxyClient
|
||||
/// </summary>
|
||||
public class RealLmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
public ILmxProxyClient Create(string host, int port, string? apiKey)
|
||||
=> new RealLmxProxyClient(host, port, apiKey);
|
||||
public ILmxProxyClient Create(string host, int port, string? apiKey, int samplingIntervalMs = 0, bool useTls = false)
|
||||
=> new RealLmxProxyClient(host, port, apiKey, samplingIntervalMs, useTls);
|
||||
}
|
||||
|
||||
@@ -14,31 +14,44 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
private Subscription? _subscription;
|
||||
private readonly Dictionary<string, MonitoredItem> _monitoredItems = new();
|
||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
|
||||
private volatile bool _connectionLostFired;
|
||||
private OpcUaConnectionOptions _options = new();
|
||||
|
||||
public bool IsConnected => _session?.Connected ?? false;
|
||||
public event Action? ConnectionLost;
|
||||
|
||||
public async Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
|
||||
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 = "ScadaLink-DCL",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "ScadaLink", "pki", "rejected") }
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
|
||||
};
|
||||
|
||||
await appConfig.ValidateAsync(ApplicationType.Client);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
if (opts.AutoAcceptUntrustedCerts)
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
// Discover endpoints from the server, pick the no-security one
|
||||
// Discover endpoints from the server, pick the preferred security mode
|
||||
EndpointDescription? endpoint;
|
||||
try
|
||||
{
|
||||
@@ -49,7 +62,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var endpoints = discoveryClient.GetEndpoints(null);
|
||||
#pragma warning restore CS0618
|
||||
endpoint = endpoints
|
||||
.Where(e => e.SecurityMode == MessageSecurityMode.None)
|
||||
.Where(e => e.SecurityMode == preferredSecurityMode)
|
||||
.FirstOrDefault() ?? endpoints.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
@@ -66,17 +79,24 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
#pragma warning restore CS0618
|
||||
_session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaLink-DCL-Session", 60000, null, null, cancellationToken);
|
||||
"ScadaLink-DCL-Session", (uint)opts.SessionTimeoutMs, null, null, cancellationToken);
|
||||
|
||||
// Detect server going offline via keep-alive failures
|
||||
_connectionLostFired = false;
|
||||
_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 = "ScadaLink",
|
||||
PublishingEnabled = true,
|
||||
PublishingInterval = 1000,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 30,
|
||||
MaxNotificationsPerPublish = 100
|
||||
PublishingInterval = opts.PublishingIntervalMs,
|
||||
KeepAliveCount = (uint)opts.KeepAliveCount,
|
||||
LifetimeCount = (uint)opts.LifetimeCount,
|
||||
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
|
||||
};
|
||||
|
||||
_session.AddSubscription(_subscription);
|
||||
@@ -92,6 +112,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
if (_session != null)
|
||||
{
|
||||
_session.KeepAlive -= OnSessionKeepAlive;
|
||||
await _session.CloseAsync(cancellationToken);
|
||||
_session = null;
|
||||
}
|
||||
@@ -112,8 +133,8 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
DisplayName = nodeId,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = 1000,
|
||||
QueueSize = 10,
|
||||
SamplingInterval = _options.SamplingIntervalMs,
|
||||
QueueSize = (uint)_options.QueueSize,
|
||||
DiscardOldest = true
|
||||
};
|
||||
|
||||
@@ -188,6 +209,20 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
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.
|
||||
/// </summary>
|
||||
private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
|
||||
{
|
||||
if (ServiceResult.IsBad(e.Status))
|
||||
{
|
||||
if (_connectionLostFired) return;
|
||||
_connectionLostFired = true;
|
||||
ConnectionLost?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
|
||||
Reference in New Issue
Block a user