Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging
Communication Layer (WP-1–5): - 8 message patterns with correlation IDs, per-pattern timeouts - Central/Site communication actors, transport heartbeat config - Connection failure handling (no central buffering, debug streams killed) Data Connection Layer (WP-6–14, WP-34): - Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting) - OPC UA + LmxProxy adapters behind IDataConnection - Auto-reconnect, bad quality propagation, transparent re-subscribe - Write-back, tag path resolution with retry, health reporting - Protocol extensibility via DataConnectionFactory Site Runtime (WP-15–25, WP-32–33): - ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher) - AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state) - SharedScriptLibrary (inline execution), ScriptRuntimeContext (API) - ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout) - Recursion limit (default 10), call direction enforcement - SiteStreamManager (per-subscriber bounded buffers, fire-and-forget) - Debug view backend (snapshot + stream), concurrency serialization - Local artifact storage (4 SQLite tables) Health Monitoring (WP-26–28): - SiteHealthCollector (thread-safe counters, connection state) - HealthReportSender (30s interval, monotonic sequence numbers) - CentralHealthAggregator (offline detection 60s, online recovery) Site Event Logging (WP-29–31): - SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC) - EventLogPurgeService (30-day retention, 1GB cap) - EventLogQueryService (filters, keyword search, keyset pagination) 541 tests pass, zero warnings.
This commit is contained in:
476
src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs
Normal file
476
src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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!;
|
||||
|
||||
/// <summary>
|
||||
/// Active subscriptions: instanceUniqueName → set of tag paths.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, HashSet<string>> _subscriptionsByInstance = new();
|
||||
|
||||
/// <summary>
|
||||
/// Subscription IDs returned by the adapter: tagPath → subscriptionId.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> _subscriptionIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tags whose path resolution failed and are awaiting retry.
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _unresolvedTags = new();
|
||||
|
||||
/// <summary>
|
||||
/// Subscribers: instanceUniqueName → IActorRef (the Instance Actor).
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, IActorRef> _subscribers = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tracks total subscribed and resolved tags for health reporting.
|
||||
/// </summary>
|
||||
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<string, string>()).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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.Commons.Messages.DataConnection;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-34: Protocol extensibility — manages DataConnectionActor instances.
|
||||
/// Routes messages to the correct connection actor based on connection name.
|
||||
/// Adding a new protocol = implement IDataConnection + register with IDataConnectionFactory.
|
||||
/// </summary>
|
||||
public class DataConnectionManagerActor : ReceiveActor
|
||||
{
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly IDataConnectionFactory _factory;
|
||||
private readonly DataConnectionOptions _options;
|
||||
private readonly Dictionary<string, IActorRef> _connectionActors = new();
|
||||
|
||||
public DataConnectionManagerActor(
|
||||
IDataConnectionFactory factory,
|
||||
DataConnectionOptions options)
|
||||
{
|
||||
_factory = factory;
|
||||
_options = options;
|
||||
|
||||
Receive<CreateConnectionCommand>(HandleCreateConnection);
|
||||
Receive<SubscribeTagsRequest>(HandleRoute);
|
||||
Receive<UnsubscribeTagsRequest>(HandleRoute);
|
||||
Receive<WriteTagRequest>(HandleRouteWrite);
|
||||
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
|
||||
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
|
||||
}
|
||||
|
||||
private void HandleCreateConnection(CreateConnectionCommand command)
|
||||
{
|
||||
if (_connectionActors.ContainsKey(command.ConnectionName))
|
||||
{
|
||||
_log.Warning("Connection {0} already exists", command.ConnectionName);
|
||||
return;
|
||||
}
|
||||
|
||||
// WP-34: Factory creates the correct adapter based on protocol type
|
||||
var adapter = _factory.Create(command.ProtocolType, command.ConnectionDetails);
|
||||
|
||||
var props = Props.Create(() => new DataConnectionActor(
|
||||
command.ConnectionName, adapter, _options));
|
||||
|
||||
var actorRef = Context.ActorOf(props, command.ConnectionName);
|
||||
_connectionActors[command.ConnectionName] = actorRef;
|
||||
|
||||
_log.Info("Created DataConnectionActor for {0} (protocol={1})",
|
||||
command.ConnectionName, command.ProtocolType);
|
||||
}
|
||||
|
||||
private void HandleRoute(SubscribeTagsRequest request)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
|
||||
actor.Forward(request);
|
||||
else
|
||||
{
|
||||
_log.Warning("No connection actor for {0}", request.ConnectionName);
|
||||
Sender.Tell(new SubscribeTagsResponse(
|
||||
request.CorrelationId, request.InstanceUniqueName, false,
|
||||
$"Unknown connection: {request.ConnectionName}", DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRoute(UnsubscribeTagsRequest request)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
|
||||
actor.Forward(request);
|
||||
else
|
||||
_log.Warning("No connection actor for {0} during unsubscribe", request.ConnectionName);
|
||||
}
|
||||
|
||||
private void HandleRouteWrite(WriteTagRequest request)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
|
||||
actor.Forward(request);
|
||||
else
|
||||
{
|
||||
_log.Warning("No connection actor for {0}", request.ConnectionName);
|
||||
Sender.Tell(new WriteTagResponse(
|
||||
request.CorrelationId, false,
|
||||
$"Unknown connection: {request.ConnectionName}", DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRemoveConnection(RemoveConnectionCommand command)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
|
||||
{
|
||||
Context.Stop(actor);
|
||||
_connectionActors.Remove(command.ConnectionName);
|
||||
_log.Info("Removed DataConnectionActor for {0}", command.ConnectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGetAllHealthReports(GetAllHealthReports _)
|
||||
{
|
||||
// Forward health report requests to all connection actors
|
||||
foreach (var actor in _connectionActors.Values)
|
||||
{
|
||||
actor.Forward(new DataConnectionActor.GetHealthReport());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OneForOneStrategy with Restart for connection actors — a failed connection
|
||||
/// should restart and attempt reconnection.
|
||||
/// </summary>
|
||||
protected override SupervisorStrategy SupervisorStrategy()
|
||||
{
|
||||
return new OneForOneStrategy(
|
||||
maxNrOfRetries: 10,
|
||||
withinTimeRange: TimeSpan.FromMinutes(1),
|
||||
decider: Decider.From(ex =>
|
||||
{
|
||||
_log.Warning(ex, "DataConnectionActor threw exception, restarting");
|
||||
return Directive.Restart;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to create a new data connection actor for a specific protocol.
|
||||
/// </summary>
|
||||
public record CreateConnectionCommand(
|
||||
string ConnectionName,
|
||||
string ProtocolType,
|
||||
IDictionary<string, string> ConnectionDetails);
|
||||
|
||||
/// <summary>
|
||||
/// Command to remove a data connection actor.
|
||||
/// </summary>
|
||||
public record RemoveConnectionCommand(string ConnectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Request for health reports from all active connections.
|
||||
/// </summary>
|
||||
public record GetAllHealthReports;
|
||||
120
src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs
Normal file
120
src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Abstraction over the LmxProxy SDK client for testability.
|
||||
/// The actual LmxProxyClient SDK lives in a separate repo; this interface
|
||||
/// defines the contract the adapter depends on.
|
||||
///
|
||||
/// LmxProxy uses gRPC streaming for subscriptions and a session-based model
|
||||
/// with keep-alive for connection management.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens a session to the LmxProxy server. Returns a session ID.
|
||||
/// </summary>
|
||||
Task<string> OpenSessionAsync(string host, int port, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes the current session.
|
||||
/// </summary>
|
||||
Task CloseSessionAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a keep-alive to maintain the session.
|
||||
/// </summary>
|
||||
Task SendKeepAliveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
bool IsConnected { get; }
|
||||
string? SessionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to tag value changes via gRPC streaming. Returns a subscription handle.
|
||||
/// </summary>
|
||||
Task<string> SubscribeTagAsync(
|
||||
string tagPath,
|
||||
Action<string, object?, DateTime, bool> onValueChanged,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task UnsubscribeTagAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(object? Value, DateTime Timestamp, bool IsGood)> ReadTagAsync(
|
||||
string tagPath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating ILmxProxyClient instances.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClientFactory
|
||||
{
|
||||
ILmxProxyClient Create();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that creates stub LmxProxy clients.
|
||||
/// In production, this would create real LmxProxy SDK client instances.
|
||||
/// </summary>
|
||||
public class DefaultLmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
public ILmxProxyClient Create() => new StubLmxProxyClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub LmxProxy client for development/testing.
|
||||
/// </summary>
|
||||
internal class StubLmxProxyClient : ILmxProxyClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public string? SessionId { get; private set; }
|
||||
|
||||
public Task<string> OpenSessionAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString();
|
||||
IsConnected = true;
|
||||
return Task.FromResult(SessionId);
|
||||
}
|
||||
|
||||
public Task CloseSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = false;
|
||||
SessionId = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendKeepAliveAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> SubscribeTagAsync(
|
||||
string tagPath, Action<string, object?, DateTime, bool> onValueChanged,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
public Task UnsubscribeTagAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? Value, DateTime Timestamp, bool IsGood)> ReadTagAsync(
|
||||
string tagPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<(object?, DateTime, bool)>((null, DateTime.UtcNow, true));
|
||||
}
|
||||
|
||||
public Task<bool> WriteTagAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
SessionId = null;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
94
src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs
Normal file
94
src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <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 DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a monitored item subscription for a node. Returns a subscription handle.
|
||||
/// </summary>
|
||||
Task<string> CreateSubscriptionAsync(
|
||||
string nodeId,
|
||||
Action<string, object?, DateTime, uint> onValueChanged,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
|
||||
string nodeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating IOpcUaClient instances.
|
||||
/// </summary>
|
||||
public interface IOpcUaClientFactory
|
||||
{
|
||||
IOpcUaClient Create();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that creates stub OPC UA clients.
|
||||
/// In production, this would create real OPC UA SDK client instances.
|
||||
/// </summary>
|
||||
public class DefaultOpcUaClientFactory : IOpcUaClientFactory
|
||||
{
|
||||
public IOpcUaClient Create() => new StubOpcUaClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub OPC UA client for development/testing. A real implementation would
|
||||
/// wrap the OPC Foundation .NET Standard Library.
|
||||
/// </summary>
|
||||
internal class StubOpcUaClient : IOpcUaClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public Task ConnectAsync(string endpointUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> CreateSubscriptionAsync(
|
||||
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
|
||||
string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<(object?, DateTime, uint)>((null, DateTime.UtcNow, 0));
|
||||
}
|
||||
|
||||
public Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<uint>(0); // Good status
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: LmxProxy adapter implementing IDataConnection.
|
||||
/// Maps IDataConnection to LmxProxy SDK calls.
|
||||
///
|
||||
/// LmxProxy-specific behavior:
|
||||
/// - Session-based connection with 30s keep-alive
|
||||
/// - gRPC streaming for subscriptions
|
||||
/// - SessionId management (required for all operations)
|
||||
/// </summary>
|
||||
public class LmxProxyDataConnection : IDataConnection
|
||||
{
|
||||
private readonly ILmxProxyClientFactory _clientFactory;
|
||||
private readonly ILogger<LmxProxyDataConnection> _logger;
|
||||
private ILmxProxyClient? _client;
|
||||
private string _host = "localhost";
|
||||
private int _port = 5000;
|
||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||
private Timer? _keepAliveTimer;
|
||||
|
||||
private readonly Dictionary<string, string> _subscriptionHandles = new();
|
||||
|
||||
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ConnectionHealth Status => _status;
|
||||
|
||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_host = connectionDetails.TryGetValue("Host", out var host) ? host : "localhost";
|
||||
if (connectionDetails.TryGetValue("Port", out var portStr) && int.TryParse(portStr, out var port))
|
||||
_port = port;
|
||||
|
||||
_status = ConnectionHealth.Connecting;
|
||||
_client = _clientFactory.Create();
|
||||
|
||||
var sessionId = await _client.OpenSessionAsync(_host, _port, cancellationToken);
|
||||
_status = ConnectionHealth.Connected;
|
||||
|
||||
// Start 30s keep-alive timer per design spec
|
||||
_keepAliveTimer = new Timer(
|
||||
async _ => await SendKeepAliveAsync(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
_logger.LogInformation("LmxProxy connected to {Host}:{Port}, sessionId={SessionId}", _host, _port, sessionId);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
_keepAliveTimer = null;
|
||||
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.CloseSessionAsync(cancellationToken);
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
_logger.LogInformation("LmxProxy disconnected from {Host}:{Port}", _host, _port);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var handle = await _client!.SubscribeTagAsync(
|
||||
tagPath,
|
||||
(path, value, timestamp, isGood) =>
|
||||
{
|
||||
var quality = isGood ? QualityCode.Good : QualityCode.Bad;
|
||||
callback(path, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_subscriptionHandles[handle] = tagPath;
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.UnsubscribeTagAsync(subscriptionId, cancellationToken);
|
||||
_subscriptionHandles.Remove(subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var (value, timestamp, isGood) = await _client!.ReadTagAsync(tagPath, cancellationToken);
|
||||
var quality = isGood ? QualityCode.Good : QualityCode.Bad;
|
||||
|
||||
if (!isGood)
|
||||
return new ReadResult(false, null, "LmxProxy read returned bad quality");
|
||||
|
||||
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, ReadResult>();
|
||||
foreach (var tagPath in tagPaths)
|
||||
{
|
||||
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var success = await _client!.WriteTagAsync(tagPath, value, cancellationToken);
|
||||
return success
|
||||
? new WriteResult(true, null)
|
||||
: new WriteResult(false, "LmxProxy write failed");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, WriteResult>();
|
||||
foreach (var (tagPath, value) in values)
|
||||
{
|
||||
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, object?> values, string flagPath, object? flagValue,
|
||||
string responsePath, object? responseValue, TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allValues = new Dictionary<string, object?>(values) { [flagPath] = flagValue };
|
||||
var writeResults = await WriteBatchAsync(allValues, cancellationToken);
|
||||
|
||||
if (writeResults.Values.Any(r => !r.Success))
|
||||
return false;
|
||||
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var readResult = await ReadAsync(responsePath, cancellationToken);
|
||||
if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue))
|
||||
return true;
|
||||
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
_keepAliveTimer = null;
|
||||
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.DisposeAsync();
|
||||
_client = null;
|
||||
}
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_client == null || !_client.IsConnected)
|
||||
throw new InvalidOperationException("LmxProxy client is not connected.");
|
||||
}
|
||||
|
||||
private async Task SendKeepAliveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client?.IsConnected == true)
|
||||
await _client.SendKeepAliveAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "LmxProxy keep-alive failed for {Host}:{Port}", _host, _port);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP-7: OPC UA adapter implementing IDataConnection.
|
||||
/// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction.
|
||||
///
|
||||
/// OPC UA mapping:
|
||||
/// - TagPath → NodeId (e.g., "ns=2;s=MyDevice.Temperature")
|
||||
/// - Subscribe → MonitoredItem with DataChangeNotification
|
||||
/// - Read/Write → Read/Write service calls
|
||||
/// - Quality → OPC UA StatusCode mapping
|
||||
/// </summary>
|
||||
public class OpcUaDataConnection : IDataConnection
|
||||
{
|
||||
private readonly IOpcUaClientFactory _clientFactory;
|
||||
private readonly ILogger<OpcUaDataConnection> _logger;
|
||||
private IOpcUaClient? _client;
|
||||
private string _endpointUrl = string.Empty;
|
||||
private ConnectionHealth _status = ConnectionHealth.Disconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Maps subscription IDs to their tag paths for cleanup.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string> _subscriptionHandles = new();
|
||||
|
||||
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ConnectionHealth Status => _status;
|
||||
|
||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_endpointUrl = connectionDetails.TryGetValue("EndpointUrl", out var url) ? url : "opc.tcp://localhost:4840";
|
||||
_status = ConnectionHealth.Connecting;
|
||||
|
||||
_client = _clientFactory.Create();
|
||||
await _client.ConnectAsync(_endpointUrl, cancellationToken);
|
||||
|
||||
_status = ConnectionHealth.Connected;
|
||||
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.DisconnectAsync(cancellationToken);
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
_logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var subscriptionId = await _client!.CreateSubscriptionAsync(
|
||||
tagPath,
|
||||
(nodeId, value, timestamp, statusCode) =>
|
||||
{
|
||||
var quality = MapStatusCode(statusCode);
|
||||
callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_subscriptionHandles[subscriptionId] = tagPath;
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken);
|
||||
_subscriptionHandles.Remove(subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
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}");
|
||||
|
||||
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, ReadResult>();
|
||||
foreach (var tagPath in tagPaths)
|
||||
{
|
||||
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var statusCode = await _client!.WriteValueAsync(tagPath, value, cancellationToken);
|
||||
if (statusCode != 0)
|
||||
return new WriteResult(false, $"OPC UA write failed with status: 0x{statusCode:X8}");
|
||||
|
||||
return new WriteResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, WriteResult>();
|
||||
foreach (var (tagPath, value) in values)
|
||||
{
|
||||
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, object?> values, string flagPath, object? flagValue,
|
||||
string responsePath, object? responseValue, TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Write all values including the flag
|
||||
var allValues = new Dictionary<string, object?>(values) { [flagPath] = flagValue };
|
||||
var writeResults = await WriteBatchAsync(allValues, cancellationToken);
|
||||
|
||||
if (writeResults.Values.Any(r => !r.Success))
|
||||
return false;
|
||||
|
||||
// Poll for response value within timeout
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var readResult = await ReadAsync(responsePath, cancellationToken);
|
||||
if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue))
|
||||
return true;
|
||||
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.DisposeAsync();
|
||||
_client = null;
|
||||
}
|
||||
_status = ConnectionHealth.Disconnected;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_client == null || !_client.IsConnected)
|
||||
throw new InvalidOperationException("OPC UA client is not connected.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OPC UA StatusCode to QualityCode.
|
||||
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
|
||||
/// </summary>
|
||||
private static QualityCode MapStatusCode(uint statusCode)
|
||||
{
|
||||
if (statusCode == 0) return QualityCode.Good;
|
||||
if ((statusCode & 0x80000000) != 0) return QualityCode.Bad;
|
||||
return QualityCode.Uncertain;
|
||||
}
|
||||
}
|
||||
43
src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs
Normal file
43
src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
using ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer;
|
||||
|
||||
/// <summary>
|
||||
/// WP-34: Default factory that resolves protocol type strings to IDataConnection adapters.
|
||||
/// Protocol extensibility: register new adapters via the constructor or AddAdapter method.
|
||||
/// </summary>
|
||||
public class DataConnectionFactory : IDataConnectionFactory
|
||||
{
|
||||
private readonly Dictionary<string, Func<IDictionary<string, string>, IDataConnection>> _factories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
public DataConnectionFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
|
||||
// Register built-in protocols
|
||||
RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
|
||||
new DefaultOpcUaClientFactory(), _loggerFactory.CreateLogger<OpcUaDataConnection>()));
|
||||
RegisterAdapter("LmxProxy", details => new LmxProxyDataConnection(
|
||||
new DefaultLmxProxyClientFactory(), _loggerFactory.CreateLogger<LmxProxyDataConnection>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new protocol adapter factory. This is the extension point
|
||||
/// for adding new protocols without modifying existing code.
|
||||
/// </summary>
|
||||
public void RegisterAdapter(string protocolType, Func<IDictionary<string, string>, IDataConnection> factory)
|
||||
{
|
||||
_factories[protocolType] = factory;
|
||||
}
|
||||
|
||||
public IDataConnection Create(string protocolType, IDictionary<string, string> connectionDetails)
|
||||
{
|
||||
if (!_factories.TryGetValue(protocolType, out var factory))
|
||||
throw new ArgumentException($"Unknown protocol type: {protocolType}. Registered protocols: {string.Join(", ", _factories.Keys)}");
|
||||
|
||||
return factory(connectionDetails);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
namespace ScadaLink.DataConnectionLayer;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Data Connection Layer.
|
||||
/// </summary>
|
||||
public class DataConnectionOptions
|
||||
{
|
||||
/// <summary>Fixed interval between reconnect attempts after disconnect.</summary>
|
||||
public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Interval for retrying failed tag path resolution.</summary>
|
||||
public TimeSpan TagResolutionRetryInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Timeout for synchronous write operations to devices.</summary>
|
||||
public TimeSpan WriteTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>LmxProxy keep-alive interval for gRPC sessions.</summary>
|
||||
public TimeSpan LmxProxyKeepAliveInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
18
src/ScadaLink.DataConnectionLayer/IDataConnectionFactory.cs
Normal file
18
src/ScadaLink.DataConnectionLayer/IDataConnectionFactory.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ScadaLink.Commons.Interfaces.Protocol;
|
||||
|
||||
namespace ScadaLink.DataConnectionLayer;
|
||||
|
||||
/// <summary>
|
||||
/// WP-34: Factory for creating IDataConnection adapters based on protocol type.
|
||||
/// Adding a new protocol = implement IDataConnection + register with the factory.
|
||||
/// </summary>
|
||||
public interface IDataConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an IDataConnection adapter for the specified protocol type.
|
||||
/// </summary>
|
||||
/// <param name="protocolType">Protocol identifier (e.g., "OpcUa", "LmxProxy").</param>
|
||||
/// <param name="connectionDetails">Protocol-specific connection parameters.</param>
|
||||
/// <returns>A configured but not yet connected IDataConnection instance.</returns>
|
||||
IDataConnection Create(string protocolType, IDictionary<string, string> connectionDetails);
|
||||
}
|
||||
@@ -8,8 +8,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,13 +6,19 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddDataConnectionLayer(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddOptions<DataConnectionOptions>()
|
||||
.BindConfiguration("DataConnectionLayer");
|
||||
|
||||
// WP-34: Register the factory for protocol extensibility
|
||||
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDataConnectionLayerActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
// Actor registration happens in AkkaHostedService or SiteCommunicationActor setup.
|
||||
// DataConnectionManagerActor and DataConnectionActor instances are created by the actor system.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user