using Akka.Actor; using Akka.Event; using ScadaLink.Commons.Interfaces.Protocol; using ScadaLink.Commons.Messages.DataConnection; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.DataConnectionLayer.Actors; /// /// WP-6: Connection actor using Akka.NET Become/Stash pattern for lifecycle state machine. /// /// States: /// - Connecting: stash subscribe/write requests; attempts connection /// - Connected: unstash and process all requests /// - Reconnecting: push bad quality for all subscribed tags, stash new requests, /// fixed-interval reconnect /// /// WP-9: Auto-reconnect with bad quality on disconnect. /// WP-10: Transparent re-subscribe after reconnection. /// WP-11: Write-back support (synchronous failure to caller, no S&F). /// WP-12: Tag path resolution with retry. /// WP-13: Health reporting (connection status + tag resolution counts). /// WP-14: Subscription lifecycle (register on create, cleanup on stop). /// public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers { private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly string _connectionName; private readonly IDataConnection _adapter; private readonly DataConnectionOptions _options; public IStash Stash { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!; /// /// Active subscriptions: instanceUniqueName → set of tag paths. /// private readonly Dictionary> _subscriptionsByInstance = new(); /// /// Subscription IDs returned by the adapter: tagPath → subscriptionId. /// private readonly Dictionary _subscriptionIds = new(); /// /// Tags whose path resolution failed and are awaiting retry. /// private readonly HashSet _unresolvedTags = new(); /// /// Subscribers: instanceUniqueName → IActorRef (the Instance Actor). /// private readonly Dictionary _subscribers = new(); /// /// Tracks total subscribed and resolved tags for health reporting. /// private int _totalSubscribed; private int _resolvedTags; public DataConnectionActor( string connectionName, IDataConnection adapter, DataConnectionOptions options) { _connectionName = connectionName; _adapter = adapter; _options = options; } protected override void PreStart() { _log.Info("DataConnectionActor [{0}] starting in Connecting state", _connectionName); BecomeConnecting(); } protected override void PostStop() { _log.Info("DataConnectionActor [{0}] stopping — disposing adapter", _connectionName); // Clean up the adapter asynchronously _ = _adapter.DisposeAsync().AsTask(); } protected override void OnReceive(object message) { // Default handler — should not be reached due to Become Unhandled(message); } // ── Connecting State ── private void BecomeConnecting() { _log.Info("[{0}] Entering Connecting state", _connectionName); Become(Connecting); Self.Tell(new AttemptConnect()); } private void Connecting(object message) { switch (message) { case AttemptConnect: HandleAttemptConnect(); break; case ConnectResult result: HandleConnectResult(result); break; case SubscribeTagsRequest: case WriteTagRequest: case UnsubscribeTagsRequest: Stash.Stash(); break; case GetHealthReport: ReplyWithHealthReport(); break; default: Unhandled(message); break; } } // ── Connected State ── private void BecomeConnected() { _log.Info("[{0}] Entering Connected state", _connectionName); Become(Connected); Stash.UnstashAll(); } private void Connected(object message) { switch (message) { case SubscribeTagsRequest req: HandleSubscribe(req); break; case UnsubscribeTagsRequest req: HandleUnsubscribe(req); break; case WriteTagRequest req: HandleWrite(req); break; case AdapterDisconnected: HandleDisconnect(); break; case RetryTagResolution: HandleRetryTagResolution(); break; case GetHealthReport: ReplyWithHealthReport(); break; default: Unhandled(message); break; } } // ── Reconnecting State ── private void BecomeReconnecting() { _log.Warning("[{0}] Entering Reconnecting state", _connectionName); Become(Reconnecting); // WP-9: Push bad quality for all subscribed tags on disconnect PushBadQualityForAllTags(); // Schedule reconnect attempt Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); } private void Reconnecting(object message) { switch (message) { case AttemptConnect: HandleAttemptConnect(); break; case ConnectResult result: HandleReconnectResult(result); break; case SubscribeTagsRequest: case WriteTagRequest: Stash.Stash(); break; case UnsubscribeTagsRequest req: // Allow unsubscribe even during reconnect (for cleanup on instance stop) HandleUnsubscribe(req); break; case GetHealthReport: ReplyWithHealthReport(); break; default: Unhandled(message); break; } } // ── Connection Management ── private void HandleAttemptConnect() { _log.Debug("[{0}] Attempting connection...", _connectionName); var self = Self; _adapter.ConnectAsync(new Dictionary()).ContinueWith(t => { if (t.IsCompletedSuccessfully) return new ConnectResult(true, null); return new ConnectResult(false, t.Exception?.GetBaseException().Message); }).PipeTo(self); } private void HandleConnectResult(ConnectResult result) { if (result.Success) { _log.Info("[{0}] Connection established", _connectionName); BecomeConnected(); } else { _log.Warning("[{0}] Connection failed: {1}. Retrying in {2}s", _connectionName, result.Error, _options.ReconnectInterval.TotalSeconds); Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); } } private void HandleReconnectResult(ConnectResult result) { if (result.Success) { _log.Info("[{0}] Reconnected successfully", _connectionName); // WP-10: Transparent re-subscribe — re-establish all active subscriptions ReSubscribeAll(); BecomeConnected(); } else { _log.Warning("[{0}] Reconnect failed: {1}. Retrying in {2}s", _connectionName, result.Error, _options.ReconnectInterval.TotalSeconds); Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval); } } private void HandleDisconnect() { _log.Warning("[{0}] Adapter reported disconnect", _connectionName); BecomeReconnecting(); } // ── Subscription Management (WP-14) ── private void HandleSubscribe(SubscribeTagsRequest request) { _log.Debug("[{0}] Subscribing {1} tags for instance {2}", _connectionName, request.TagPaths.Count, request.InstanceUniqueName); _subscribers[request.InstanceUniqueName] = Sender; if (!_subscriptionsByInstance.ContainsKey(request.InstanceUniqueName)) _subscriptionsByInstance[request.InstanceUniqueName] = new HashSet(); var instanceTags = _subscriptionsByInstance[request.InstanceUniqueName]; var self = Self; var sender = Sender; Task.Run(async () => { foreach (var tagPath in request.TagPaths) { if (_subscriptionIds.ContainsKey(tagPath)) { // Already subscribed — just track for this instance instanceTags.Add(tagPath); continue; } try { var subId = await _adapter.SubscribeAsync(tagPath, (path, value) => { self.Tell(new TagValueReceived(path, value)); }); _subscriptionIds[tagPath] = subId; instanceTags.Add(tagPath); _totalSubscribed++; _resolvedTags++; } catch (Exception ex) { // WP-12: Tag path resolution failure — mark as unresolved, retry later _unresolvedTags.Add(tagPath); instanceTags.Add(tagPath); _totalSubscribed++; self.Tell(new TagResolutionFailed(tagPath, ex.Message)); } } return new SubscribeTagsResponse( request.CorrelationId, request.InstanceUniqueName, true, null, DateTimeOffset.UtcNow); }).PipeTo(sender); // Start tag resolution retry timer if we have unresolved tags if (_unresolvedTags.Count > 0) { Timers.StartPeriodicTimer( "tag-resolution-retry", new RetryTagResolution(), _options.TagResolutionRetryInterval, _options.TagResolutionRetryInterval); } } private void HandleUnsubscribe(UnsubscribeTagsRequest request) { _log.Debug("[{0}] Unsubscribing all tags for instance {1}", _connectionName, request.InstanceUniqueName); if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var tags)) return; // WP-14: Cleanup on Instance Actor stop foreach (var tagPath in tags) { // Check if any other instance is still subscribed to this tag var otherSubscribers = _subscriptionsByInstance .Where(kvp => kvp.Key != request.InstanceUniqueName && kvp.Value.Contains(tagPath)) .Any(); if (!otherSubscribers && _subscriptionIds.TryGetValue(tagPath, out var subId)) { _ = _adapter.UnsubscribeAsync(subId); _subscriptionIds.Remove(tagPath); _unresolvedTags.Remove(tagPath); _totalSubscribed--; if (!_unresolvedTags.Contains(tagPath)) _resolvedTags--; } } _subscriptionsByInstance.Remove(request.InstanceUniqueName); _subscribers.Remove(request.InstanceUniqueName); } // ── Write Support (WP-11) ── private void HandleWrite(WriteTagRequest request) { _log.Debug("[{0}] Writing to tag {1}", _connectionName, request.TagPath); var sender = Sender; // WP-11: Write through DCL to device, failure returned synchronously _adapter.WriteAsync(request.TagPath, request.Value).ContinueWith(t => { if (t.IsCompletedSuccessfully) { var result = t.Result; return new WriteTagResponse( request.CorrelationId, result.Success, result.ErrorMessage, DateTimeOffset.UtcNow); } return new WriteTagResponse( request.CorrelationId, false, t.Exception?.GetBaseException().Message, DateTimeOffset.UtcNow); }).PipeTo(sender); } // ── Tag Resolution Retry (WP-12) ── private void HandleRetryTagResolution() { if (_unresolvedTags.Count == 0) { Timers.Cancel("tag-resolution-retry"); return; } _log.Debug("[{0}] Retrying resolution for {1} unresolved tags", _connectionName, _unresolvedTags.Count); var self = Self; var toResolve = _unresolvedTags.ToList(); foreach (var tagPath in toResolve) { _adapter.SubscribeAsync(tagPath, (path, value) => { self.Tell(new TagValueReceived(path, value)); }).ContinueWith(t => { if (t.IsCompletedSuccessfully) return new TagResolutionSucceeded(tagPath, t.Result) as object; return new TagResolutionFailed(tagPath, t.Exception?.GetBaseException().Message ?? "Unknown error"); }).PipeTo(self); } } // ── Bad Quality Push (WP-9) ── private void PushBadQualityForAllTags() { var now = DateTimeOffset.UtcNow; foreach (var (instanceName, tags) in _subscriptionsByInstance) { if (!_subscribers.TryGetValue(instanceName, out var subscriber)) continue; subscriber.Tell(new ConnectionQualityChanged(_connectionName, QualityCode.Bad, now)); } } // ── Re-subscribe (WP-10) ── private void ReSubscribeAll() { _log.Info("[{0}] Re-subscribing {1} tags after reconnect", _connectionName, _subscriptionIds.Count); var self = Self; var allTags = _subscriptionIds.Keys.ToList(); _subscriptionIds.Clear(); _resolvedTags = 0; foreach (var tagPath in allTags) { _adapter.SubscribeAsync(tagPath, (path, value) => { self.Tell(new TagValueReceived(path, value)); }).ContinueWith(t => { if (t.IsCompletedSuccessfully) return new TagResolutionSucceeded(tagPath, t.Result) as object; return new TagResolutionFailed(tagPath, t.Exception?.GetBaseException().Message ?? "Unknown error"); }).PipeTo(self); } } // ── Health Reporting (WP-13) ── private void ReplyWithHealthReport() { var status = _adapter.Status; Sender.Tell(new DataConnectionHealthReport( _connectionName, status, _totalSubscribed, _resolvedTags, DateTimeOffset.UtcNow)); } // ── Internal message handlers for piped async results ── private void HandleTagValueReceived(TagValueReceived msg) { // Fan out to all subscribed instances foreach (var (instanceName, tags) in _subscriptionsByInstance) { if (!tags.Contains(msg.TagPath)) continue; if (_subscribers.TryGetValue(instanceName, out var subscriber)) { subscriber.Tell(new TagValueUpdate( _connectionName, msg.TagPath, msg.Value.Value, msg.Value.Quality, msg.Value.Timestamp)); } } } // ── Internal messages ── internal record AttemptConnect; internal record ConnectResult(bool Success, string? Error); internal record AdapterDisconnected; internal record TagValueReceived(string TagPath, TagValue Value); internal record TagResolutionFailed(string TagPath, string Error); internal record TagResolutionSucceeded(string TagPath, string SubscriptionId); internal record RetryTagResolution; public record GetHealthReport; }