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:
Joseph Doherty
2026-03-19 13:27:54 -04:00
parent ffdda51990
commit 7740a3bcf9
70 changed files with 2684 additions and 541 deletions
@@ -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();