feat(lmxproxy): phase 5 — client core (ILmxProxyClient, connection, read/write/subscribe)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the LmxProxy client, typically set via the builder.
|
||||
/// </summary>
|
||||
public class ClientConfiguration
|
||||
{
|
||||
/// <summary>Maximum number of retry attempts for transient failures.</summary>
|
||||
public int MaxRetryAttempts { get; set; } = 0;
|
||||
|
||||
/// <summary>Base delay between retries (exponential backoff applied).</summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration for the LmxProxy gRPC client.
|
||||
/// </summary>
|
||||
public class ClientTlsConfiguration
|
||||
{
|
||||
/// <summary>Whether to use TLS for the gRPC connection.</summary>
|
||||
public bool UseTls { get; set; } = false;
|
||||
|
||||
/// <summary>Path to the client certificate PEM file for mTLS.</summary>
|
||||
public string? ClientCertificatePath { get; set; }
|
||||
|
||||
/// <summary>Path to the client private key PEM file for mTLS.</summary>
|
||||
public string? ClientKeyPath { get; set; }
|
||||
|
||||
/// <summary>Path to the server CA certificate PEM file for custom trust.</summary>
|
||||
public string? ServerCaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>Override the server name used for TLS verification.</summary>
|
||||
public string? ServerNameOverride { get; set; }
|
||||
|
||||
/// <summary>Whether to validate the server certificate.</summary>
|
||||
public bool ValidateServerCertificate { get; set; } = true;
|
||||
|
||||
/// <summary>Whether to allow self-signed certificates.</summary>
|
||||
public bool AllowSelfSignedCertificates { get; set; } = false;
|
||||
|
||||
/// <summary>Whether to ignore all certificate errors (dangerous).</summary>
|
||||
public bool IgnoreAllCertificateErrors { get; set; } = false;
|
||||
}
|
||||
@@ -11,4 +11,19 @@ public static class QualityExtensions
|
||||
|
||||
/// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
|
||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA 32-bit status code to the simplified <see cref="Quality"/> enum.
|
||||
/// Uses the top two bits to determine the quality family.
|
||||
/// </summary>
|
||||
public static Quality FromStatusCode(uint statusCode)
|
||||
{
|
||||
uint category = statusCode & 0xC0000000;
|
||||
return category switch
|
||||
{
|
||||
0x00000000 => Quality.Good,
|
||||
0x40000000 => Quality.Uncertain,
|
||||
_ => Quality.Bad
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
58
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs
Normal file
58
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for LmxProxy client operations.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClient : IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Gets or sets the default timeout for operations (range: 1s to 10min).</summary>
|
||||
TimeSpan DefaultTimeout { get; set; }
|
||||
|
||||
/// <summary>Connects to the LmxProxy service and establishes a session.</summary>
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Disconnects from the LmxProxy service.</summary>
|
||||
Task DisconnectAsync();
|
||||
|
||||
/// <summary>Returns true if the client has an active session.</summary>
|
||||
Task<bool> IsConnectedAsync();
|
||||
|
||||
/// <summary>Reads a single tag value.</summary>
|
||||
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads multiple tag values in a single batch.</summary>
|
||||
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes a single tag value (native TypedValue -- no string heuristics).</summary>
|
||||
Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes multiple tag values in a single batch.</summary>
|
||||
Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of values, then polls a flag tag until it matches or timeout expires.
|
||||
/// Returns (writeResults, flagReached, elapsedMs).
|
||||
/// </summary>
|
||||
Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values,
|
||||
string flagTag,
|
||||
TypedValue flagValue,
|
||||
int timeoutMs = 5000,
|
||||
int pollIntervalMs = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Subscribes to tag updates with value and error callbacks.</summary>
|
||||
Task<LmxProxyClient.ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates an API key and returns info.</summary>
|
||||
Task<LmxProxyClient.ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Returns a snapshot of client-side metrics.</summary>
|
||||
Dictionary<string, object> GetMetrics();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an API key validation check.
|
||||
/// </summary>
|
||||
public class ApiKeyInfo
|
||||
{
|
||||
/// <summary>Whether the API key is valid.</summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>Role associated with the API key.</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Description or message from the server.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks per-operation counts, errors, and latency with rolling buffer and percentile support.
|
||||
/// </summary>
|
||||
internal class ClientMetrics
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, long> _operationCounts = new();
|
||||
private readonly ConcurrentDictionary<string, long> _errorCounts = new();
|
||||
private readonly ConcurrentDictionary<string, List<long>> _latencies = new();
|
||||
private readonly Lock _latencyLock = new();
|
||||
|
||||
public void IncrementOperationCount(string operation)
|
||||
{
|
||||
_operationCounts.AddOrUpdate(operation, 1, (_, count) => count + 1);
|
||||
}
|
||||
|
||||
public void IncrementErrorCount(string operation)
|
||||
{
|
||||
_errorCounts.AddOrUpdate(operation, 1, (_, count) => count + 1);
|
||||
}
|
||||
|
||||
public void RecordLatency(string operation, long milliseconds)
|
||||
{
|
||||
lock (_latencyLock)
|
||||
{
|
||||
if (!_latencies.TryGetValue(operation, out var list))
|
||||
{
|
||||
list = [];
|
||||
_latencies[operation] = list;
|
||||
}
|
||||
list.Add(milliseconds);
|
||||
if (list.Count > 1000)
|
||||
{
|
||||
list.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetSnapshot()
|
||||
{
|
||||
var snapshot = new Dictionary<string, object>();
|
||||
|
||||
foreach (var kvp in _operationCounts)
|
||||
{
|
||||
snapshot[$"{kvp.Key}_count"] = kvp.Value;
|
||||
}
|
||||
|
||||
foreach (var kvp in _errorCounts)
|
||||
{
|
||||
snapshot[$"{kvp.Key}_errors"] = kvp.Value;
|
||||
}
|
||||
|
||||
lock (_latencyLock)
|
||||
{
|
||||
foreach (var kvp in _latencies)
|
||||
{
|
||||
var values = kvp.Value;
|
||||
if (values.Count == 0) continue;
|
||||
|
||||
double avg = values.Average();
|
||||
snapshot[$"{kvp.Key}_avg_latency_ms"] = Math.Round(avg, 2);
|
||||
snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(values, 95);
|
||||
snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(values, 99);
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static long GetPercentile(List<long> values, int percentile)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
int index = Math.Max(0, (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1);
|
||||
return sorted[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
private class CodeFirstSubscription : ISubscription
|
||||
{
|
||||
private readonly IScadaService _client;
|
||||
private readonly string _sessionId;
|
||||
private readonly List<string> _tags;
|
||||
private readonly Action<string, Vtq> _onUpdate;
|
||||
private readonly Action<Exception>? _onStreamError;
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly Action<ISubscription>? _onDispose;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _processingTask;
|
||||
private bool _disposed;
|
||||
private bool _streamErrorFired;
|
||||
|
||||
public CodeFirstSubscription(
|
||||
IScadaService client,
|
||||
string sessionId,
|
||||
List<string> tags,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError,
|
||||
ILogger<LmxProxyClient> logger,
|
||||
Action<ISubscription>? onDispose)
|
||||
{
|
||||
_client = client;
|
||||
_sessionId = sessionId;
|
||||
_tags = tags;
|
||||
_onUpdate = onUpdate;
|
||||
_onStreamError = onStreamError;
|
||||
_logger = logger;
|
||||
_onDispose = onDispose;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_processingTask = ProcessUpdatesAsync(cancellationToken);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProcessUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new SubscribeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Tags = _tags,
|
||||
SamplingMs = 1000
|
||||
};
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
|
||||
|
||||
await foreach (VtqMessage vtqMsg in _client.SubscribeAsync(request, linkedCts.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
Vtq vtq = ConvertVtqMessage(vtqMsg);
|
||||
_onUpdate(vtqMsg.Tag, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Subscription cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in subscription processing");
|
||||
FireStreamError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_onDispose?.Invoke(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void FireStreamError(Exception ex)
|
||||
{
|
||||
if (_streamErrorFired) return;
|
||||
_streamErrorFired = true;
|
||||
try { _onStreamError?.Invoke(ex); }
|
||||
catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); }
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_processingTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _processingTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* swallow timeout or cancellation */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
DisposeAsync().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ProtoBuf.Grpc.Client;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Security;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _connectionLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (IsConnected)
|
||||
return;
|
||||
|
||||
var endpoint = BuildEndpointUri();
|
||||
_logger.LogInformation("Connecting to LmxProxy at {Endpoint}", endpoint);
|
||||
|
||||
GrpcChannel channel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger);
|
||||
IScadaService client;
|
||||
try
|
||||
{
|
||||
client = channel.CreateGrpcService<IScadaService>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
channel.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
ConnectResponse response;
|
||||
try
|
||||
{
|
||||
var request = new ConnectRequest
|
||||
{
|
||||
ClientId = $"ScadaBridge-{Guid.NewGuid():N}",
|
||||
ApiKey = _apiKey ?? string.Empty
|
||||
};
|
||||
response = await client.ConnectAsync(request);
|
||||
}
|
||||
catch
|
||||
{
|
||||
channel.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
channel.Dispose();
|
||||
throw new InvalidOperationException($"Connect failed: {response.Message}");
|
||||
}
|
||||
|
||||
_channel = channel;
|
||||
_client = client;
|
||||
_sessionId = response.SessionId;
|
||||
_isConnected = true;
|
||||
|
||||
StartKeepAlive();
|
||||
|
||||
_logger.LogInformation("Connected to LmxProxy, session={SessionId}", _sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_channel = null;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_isConnected = false;
|
||||
_logger.LogError(ex, "Failed to connect to LmxProxy");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
await _connectionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
StopKeepAlive();
|
||||
|
||||
if (_client is not null && !string.IsNullOrEmpty(_sessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.DisconnectAsync(new DisconnectRequest { SessionId = _sessionId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error sending disconnect request");
|
||||
}
|
||||
}
|
||||
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_isConnected = false;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="SubscribeAsync"/>
|
||||
public async Task<ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var subscription = new CodeFirstSubscription(
|
||||
_client!,
|
||||
_sessionId,
|
||||
addresses.ToList(),
|
||||
onUpdate,
|
||||
onStreamError,
|
||||
_logger,
|
||||
sub =>
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeSubscriptions.Remove(sub);
|
||||
}
|
||||
});
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeSubscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
await subscription.StartAsync(cancellationToken);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private void StartKeepAlive()
|
||||
{
|
||||
_keepAliveTimer = new Timer(
|
||||
async _ => await KeepAliveCallback(),
|
||||
null,
|
||||
_keepAliveInterval,
|
||||
_keepAliveInterval);
|
||||
}
|
||||
|
||||
private async Task KeepAliveCallback()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client is null || string.IsNullOrEmpty(_sessionId))
|
||||
return;
|
||||
|
||||
await _client.GetConnectionStateAsync(new GetConnectionStateRequest { SessionId = _sessionId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Keep-alive failed, marking disconnected");
|
||||
StopKeepAlive();
|
||||
await MarkDisconnectedAsync(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopKeepAlive()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
_keepAliveTimer = null;
|
||||
}
|
||||
|
||||
internal async Task MarkDisconnectedAsync(Exception ex)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
await _connectionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_isConnected = false;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
|
||||
List<ISubscription> subscriptions;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
subscriptions = [.. _activeSubscriptions];
|
||||
_activeSubscriptions.Clear();
|
||||
}
|
||||
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
try { sub.Dispose(); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
|
||||
_logger.LogWarning(ex, "Client marked as disconnected");
|
||||
}
|
||||
|
||||
private Uri BuildEndpointUri()
|
||||
{
|
||||
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
|
||||
return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an active tag subscription. Dispose to unsubscribe.
|
||||
/// </summary>
|
||||
public interface ISubscription : IDisposable
|
||||
{
|
||||
/// <summary>Asynchronous disposal with cancellation support.</summary>
|
||||
Task DisposeAsync();
|
||||
}
|
||||
}
|
||||
314
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs
Normal file
314
lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC client for the LmxProxy SCADA proxy service. Uses v2 protocol with native TypedValue.
|
||||
/// </summary>
|
||||
public partial class LmxProxyClient : ILmxProxyClient
|
||||
{
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string? _apiKey;
|
||||
private readonly ClientTlsConfiguration? _tlsConfiguration;
|
||||
private readonly ClientMetrics _metrics = new();
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly List<ISubscription> _activeSubscriptions = [];
|
||||
private readonly Lock _subscriptionLock = new();
|
||||
|
||||
private GrpcChannel? _channel;
|
||||
private IScadaService? _client;
|
||||
private string _sessionId = string.Empty;
|
||||
private bool _disposed;
|
||||
private bool _isConnected;
|
||||
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private ClientConfiguration? _configuration;
|
||||
private ResiliencePipeline? _resiliencePipeline;
|
||||
private Timer? _keepAliveTimer;
|
||||
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Returns true if the client has an active session and is not disposed.</summary>
|
||||
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan DefaultTimeout
|
||||
{
|
||||
get => _defaultTimeout;
|
||||
set
|
||||
{
|
||||
if (value < TimeSpan.FromSeconds(1) || value > TimeSpan.FromMinutes(10))
|
||||
throw new ArgumentOutOfRangeException(nameof(value), "DefaultTimeout must be between 1 second and 10 minutes.");
|
||||
_defaultTimeout = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new LmxProxyClient instance.
|
||||
/// </summary>
|
||||
public LmxProxyClient(
|
||||
string host, int port, string? apiKey,
|
||||
ClientTlsConfiguration? tlsConfiguration,
|
||||
ILogger<LmxProxyClient>? logger = null)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_port = port;
|
||||
_apiKey = apiKey;
|
||||
_tlsConfiguration = tlsConfiguration;
|
||||
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets builder configuration including retry policies. Called internally by the builder.
|
||||
/// </summary>
|
||||
internal void SetBuilderConfiguration(ClientConfiguration config)
|
||||
{
|
||||
_configuration = config;
|
||||
if (config.MaxRetryAttempts > 0)
|
||||
{
|
||||
_resiliencePipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = config.MaxRetryAttempts,
|
||||
Delay = config.RetryDelay,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
ShouldHandle = new PredicateBuilder()
|
||||
.Handle<RpcException>(ex =>
|
||||
ex.StatusCode == StatusCode.Unavailable ||
|
||||
ex.StatusCode == StatusCode.DeadlineExceeded ||
|
||||
ex.StatusCode == StatusCode.ResourceExhausted ||
|
||||
ex.StatusCode == StatusCode.Aborted),
|
||||
OnRetry = args =>
|
||||
{
|
||||
_logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}",
|
||||
args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Read");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadRequest { SessionId = _sessionId, Tag = address };
|
||||
ReadResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Read failed: {response.Message}");
|
||||
return ConvertVtqMessage(response.Vtq);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("Read");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Read", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(
|
||||
IEnumerable<string> addresses, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("ReadBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() };
|
||||
ReadBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadBatchAsync(request).AsTask(), cancellationToken);
|
||||
var result = new Dictionary<string, Vtq>();
|
||||
foreach (var vtqMsg in response.Vtqs)
|
||||
{
|
||||
result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("ReadBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Write");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value };
|
||||
WriteResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Write failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("Write");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Write", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("WriteBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteBatchRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList()
|
||||
};
|
||||
WriteBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("WriteBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
|
||||
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new WriteBatchAndWaitRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(),
|
||||
FlagTag = flagTag,
|
||||
FlagValue = flagValue,
|
||||
TimeoutMs = timeoutMs,
|
||||
PollIntervalMs = pollIntervalMs
|
||||
};
|
||||
return await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new CheckApiKeyRequest { ApiKey = apiKey };
|
||||
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
|
||||
return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsConnectedAsync() => Task.FromResult(IsConnected);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
|
||||
|
||||
internal static Vtq ConvertVtqMessage(VtqMessage? msg)
|
||||
{
|
||||
if (msg is null)
|
||||
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
|
||||
|
||||
object? value = ExtractTypedValue(msg.Value);
|
||||
DateTime timestamp = msg.TimestampUtcTicks > 0
|
||||
? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc)
|
||||
: DateTime.UtcNow;
|
||||
Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u);
|
||||
return new Vtq(value, timestamp, quality);
|
||||
}
|
||||
|
||||
internal static object? ExtractTypedValue(TypedValue? tv)
|
||||
{
|
||||
if (tv is null) return null;
|
||||
|
||||
return tv.GetValueCase() switch
|
||||
{
|
||||
TypedValueCase.BoolValue => tv.BoolValue,
|
||||
TypedValueCase.Int32Value => tv.Int32Value,
|
||||
TypedValueCase.Int64Value => tv.Int64Value,
|
||||
TypedValueCase.FloatValue => tv.FloatValue,
|
||||
TypedValueCase.DoubleValue => tv.DoubleValue,
|
||||
TypedValueCase.StringValue => tv.StringValue,
|
||||
TypedValueCase.BytesValue => tv.BytesValue,
|
||||
TypedValueCase.DatetimeValue => new DateTime(tv.DatetimeValue, DateTimeKind.Utc),
|
||||
TypedValueCase.ArrayValue => tv.ArrayValue,
|
||||
TypedValueCase.None => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteWithRetry<T>(Func<Task<T>> operation, CancellationToken ct)
|
||||
{
|
||||
if (_resiliencePipeline is not null)
|
||||
{
|
||||
return await _resiliencePipeline.ExecuteAsync(
|
||||
async token => await operation(), ct);
|
||||
}
|
||||
return await operation();
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_keepAliveTimer?.Dispose();
|
||||
_channel?.Dispose();
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try { await DisconnectAsync(); } catch { /* swallow */ }
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating configured gRPC channels with TLS support.
|
||||
/// </summary>
|
||||
internal static class GrpcChannelFactory
|
||||
{
|
||||
#pragma warning disable CA1810 // Initialize reference type static fields inline
|
||||
static GrpcChannelFactory()
|
||||
#pragma warning restore CA1810
|
||||
{
|
||||
// Enable HTTP/2 over plaintext for non-TLS scenarios
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="GrpcChannel"/> with the specified address and TLS configuration.
|
||||
/// </summary>
|
||||
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
EnableMultipleHttp2Connections = true
|
||||
};
|
||||
|
||||
if (tlsConfiguration?.UseTls == true)
|
||||
{
|
||||
ConfigureTls(handler, tlsConfiguration, logger);
|
||||
}
|
||||
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = handler
|
||||
};
|
||||
|
||||
logger.LogDebug("Creating gRPC channel to {Address}, TLS={UseTls}", address, tlsConfiguration?.UseTls ?? false);
|
||||
return GrpcChannel.ForAddress(address, channelOptions);
|
||||
}
|
||||
|
||||
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tls, ILogger logger)
|
||||
{
|
||||
handler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(tls.ServerNameOverride))
|
||||
{
|
||||
handler.SslOptions.TargetHost = tls.ServerNameOverride;
|
||||
}
|
||||
|
||||
// Load client certificate for mTLS
|
||||
if (!string.IsNullOrEmpty(tls.ClientCertificatePath) && !string.IsNullOrEmpty(tls.ClientKeyPath))
|
||||
{
|
||||
var clientCert = X509Certificate2.CreateFromPemFile(tls.ClientCertificatePath, tls.ClientKeyPath);
|
||||
handler.SslOptions.ClientCertificates = [clientCert];
|
||||
logger.LogDebug("Loaded client certificate for mTLS from {Path}", tls.ClientCertificatePath);
|
||||
}
|
||||
|
||||
// Certificate validation callback
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
|
||||
{
|
||||
if (tls.IgnoreAllCertificateErrors)
|
||||
{
|
||||
logger.LogWarning("Ignoring all certificate errors (IgnoreAllCertificateErrors=true)");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tls.ValidateServerCertificate)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
// Custom CA trust store
|
||||
if (!string.IsNullOrEmpty(tls.ServerCaCertificatePath) && certificate is not null)
|
||||
{
|
||||
using var customChain = new X509Chain();
|
||||
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
customChain.ChainPolicy.CustomTrustStore.Add(X509CertificateLoader.LoadCertificateFromFile(tls.ServerCaCertificatePath));
|
||||
if (customChain.Build(new X509Certificate2(certificate)))
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tls.AllowSelfSignedCertificates && sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
{
|
||||
logger.LogWarning("Allowing self-signed certificate");
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogError("Certificate validation failed: {Errors}", sslPolicyErrors);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.LmxProxy.Client.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
|
||||
|
||||
Reference in New Issue
Block a user