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

View File

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