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