deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/

LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
This commit is contained in:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// TLS configuration for LmxProxy client connections
/// </summary>
public class ClientTlsConfiguration
{
/// <summary>
/// Gets or sets whether to use TLS for the connection
/// </summary>
public bool UseTls { get; set; } = false;
/// <summary>
/// Gets or sets the path to the client certificate file (optional for mutual TLS)
/// </summary>
public string? ClientCertificatePath { get; set; }
/// <summary>
/// Gets or sets the path to the client private key file (optional for mutual TLS)
/// </summary>
public string? ClientKeyPath { get; set; }
/// <summary>
/// Gets or sets the path to the CA certificate for server validation (optional)
/// </summary>
public string? ServerCaCertificatePath { get; set; }
/// <summary>
/// Gets or sets the server name override for certificate validation (optional)
/// </summary>
public string? ServerNameOverride { get; set; }
/// <summary>
/// Gets or sets whether to validate the server certificate
/// </summary>
public bool ValidateServerCertificate { get; set; } = true;
/// <summary>
/// Gets or sets whether to allow self-signed certificates (for testing only)
/// </summary>
public bool AllowSelfSignedCertificates { get; set; } = false;
/// <summary>
/// Gets or sets whether to ignore all certificate errors (DANGEROUS - for testing only)
/// WARNING: This completely disables certificate validation and should never be used in production
/// </summary>
public bool IgnoreAllCertificateErrors { get; set; } = false;
}

View File

@@ -0,0 +1,49 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Represents the connection state of an LmxProxy client.
/// </summary>
public enum ConnectionState
{
/// <summary>Not connected to the server.</summary>
Disconnected,
/// <summary>Connection attempt in progress.</summary>
Connecting,
/// <summary>Connected and ready for operations.</summary>
Connected,
/// <summary>Graceful disconnect in progress.</summary>
Disconnecting,
/// <summary>Connection failed with an error.</summary>
Error,
/// <summary>Attempting to re-establish a lost connection.</summary>
Reconnecting
}
/// <summary>
/// Event arguments for connection state change notifications.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>The previous connection state.</summary>
public ConnectionState OldState { get; }
/// <summary>The new connection state.</summary>
public ConnectionState NewState { get; }
/// <summary>Optional message describing the state change (e.g., error details).</summary>
public string? Message { get; }
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null)
{
OldState = oldState;
NewState = newState;
Message = message;
}
}

View File

@@ -0,0 +1,118 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// OPC-style quality codes for SCADA data values.
/// Based on OPC DA quality encoding as a single byte:
/// bits 76 = major (00=Bad, 01=Uncertain, 11=Good),
/// bits 52 = substatus, bits 10 = limit (00=None, 01=Low, 10=High, 11=Constant).
/// </summary>
public enum Quality : byte
{
/// <summary>Bad non-specific.</summary>
Bad = 0,
/// <summary>Bad configuration error in the server.</summary>
Bad_ConfigError = 4,
/// <summary>Bad input source is not connected.</summary>
Bad_NotConnected = 8,
/// <summary>Bad device failure detected.</summary>
Bad_DeviceFailure = 12,
/// <summary>Bad sensor failure detected.</summary>
Bad_SensorFailure = 16,
/// <summary>Bad last known value (communication lost, value stale).</summary>
Bad_LastKnownValue = 20,
/// <summary>Bad communication failure.</summary>
Bad_CommFailure = 24,
/// <summary>Bad item is out of service.</summary>
Bad_OutOfService = 28,
/// <summary>Uncertain non-specific.</summary>
Uncertain = 64,
/// <summary>Uncertain non-specific, low limited.</summary>
Uncertain_LowLimited = 65,
/// <summary>Uncertain non-specific, high limited.</summary>
Uncertain_HighLimited = 66,
/// <summary>Uncertain non-specific, constant.</summary>
Uncertain_Constant = 67,
/// <summary>Uncertain last usable value.</summary>
Uncertain_LastUsable = 68,
/// <summary>Uncertain last usable value, low limited.</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>Uncertain last usable value, high limited.</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>Uncertain last usable value, constant.</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>Uncertain sensor not accurate.</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>Uncertain sensor not accurate, low limited.</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>Uncertain sensor not accurate, high limited.</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>Uncertain sensor not accurate, constant.</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>Uncertain engineering units exceeded.</summary>
Uncertain_EuExceeded = 84,
/// <summary>Uncertain engineering units exceeded, low limited.</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>Uncertain engineering units exceeded, high limited.</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>Uncertain engineering units exceeded, constant.</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>Uncertain sub-normal operating conditions.</summary>
Uncertain_SubNormal = 88,
/// <summary>Uncertain sub-normal, low limited.</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>Uncertain sub-normal, high limited.</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>Uncertain sub-normal, constant.</summary>
Uncertain_SubNormal_C = 91,
/// <summary>Good non-specific.</summary>
Good = 192,
/// <summary>Good low limited.</summary>
Good_LowLimited = 193,
/// <summary>Good high limited.</summary>
Good_HighLimited = 194,
/// <summary>Good constant.</summary>
Good_Constant = 195,
/// <summary>Good local override active.</summary>
Good_LocalOverride = 216,
/// <summary>Good local override active, low limited.</summary>
Good_LocalOverride_LL = 217,
/// <summary>Good local override active, high limited.</summary>
Good_LocalOverride_HL = 218,
/// <summary>Good local override active, constant.</summary>
Good_LocalOverride_C = 219
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
public static class QualityExtensions
{
public static bool IsGood(this Quality q) => (byte)q >= 128;
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
public static bool IsBad(this Quality q) => (byte)q < 64;
}

View File

@@ -0,0 +1,444 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
// ────────────────────────────────────────────────────────────────
// Service contract
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Code-first gRPC service contract for SCADA operations.
/// </summary>
[ServiceContract(Name = "scada.ScadaService")]
public interface IScadaService
{
/// <summary>Establishes a connection with the SCADA service.</summary>
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
/// <summary>Terminates a SCADA service connection.</summary>
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
/// <summary>Retrieves the current state of a SCADA connection.</summary>
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
/// <summary>Reads a single tag value from the SCADA system.</summary>
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
/// <summary>Writes multiple values to tags in the SCADA system in a batch operation.</summary>
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
/// <summary>Validates an API key for authentication.</summary>
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
}
// ────────────────────────────────────────────────────────────────
// VTQ message
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Value-Timestamp-Quality message transmitted over gRPC.
/// All values are string-encoded; timestamps are UTC ticks.
/// </summary>
[DataContract]
public class VtqMessage
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value encoded as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
[DataMember(Order = 3)]
public long TimestampUtcTicks { get; set; }
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
[DataMember(Order = 4)]
public string Quality { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Connect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to establish a session with the proxy server.</summary>
[DataContract]
public class ConnectRequest
{
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
[DataMember(Order = 1)]
public string ClientId { get; set; } = string.Empty;
/// <summary>API key for authentication (empty if none required).</summary>
[DataMember(Order = 2)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from a Connect call.</summary>
[DataContract]
public class ConnectResponse
{
/// <summary>Whether the connection was established successfully.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Session ID (32-char hex GUID). Only valid when <see cref="Success"/> is <c>true</c>.</summary>
[DataMember(Order = 3)]
public string SessionId { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Disconnect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to terminate a session.</summary>
[DataContract]
public class DisconnectRequest
{
/// <summary>Active session ID to disconnect.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response from a Disconnect call.</summary>
[DataContract]
public class DisconnectResponse
{
/// <summary>Whether the disconnect succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// GetConnectionState
// ────────────────────────────────────────────────────────────────
/// <summary>Request to query connection state for a session.</summary>
[DataContract]
public class GetConnectionStateRequest
{
/// <summary>Session ID to query.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response with connection state information.</summary>
[DataContract]
public class GetConnectionStateResponse
{
/// <summary>Whether the session is currently connected.</summary>
[DataMember(Order = 1)]
public bool IsConnected { get; set; }
/// <summary>Client identifier for this session.</summary>
[DataMember(Order = 2)]
public string ClientId { get; set; } = string.Empty;
/// <summary>UTC ticks when the connection was established.</summary>
[DataMember(Order = 3)]
public long ConnectedSinceUtcTicks { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Read
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read a single tag.</summary>
[DataContract]
public class ReadRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to read.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Read call.</summary>
[DataContract]
public class ReadResponse
{
/// <summary>Whether the read succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message if the read failed.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>The value-timestamp-quality result.</summary>
[DataMember(Order = 3)]
public VtqMessage? Vtq { get; set; }
}
// ────────────────────────────────────────────────────────────────
// ReadBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read multiple tags in a single round-trip.</summary>
[DataContract]
public class ReadBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to read.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
}
/// <summary>Response from a batch Read call.</summary>
[DataContract]
public class ReadBatchResponse
{
/// <summary>False if any tag read failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>VTQ results in the same order as the request tags.</summary>
[DataMember(Order = 3)]
public List<VtqMessage> Vtqs { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// Write
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write a single tag value.</summary>
[DataContract]
public class WriteRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to write.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string (parsed server-side).</summary>
[DataMember(Order = 3)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Write call.</summary>
[DataContract]
public class WriteResponse
{
/// <summary>Whether the write succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteItem / WriteResult
// ────────────────────────────────────────────────────────────────
/// <summary>A single tag-value pair for batch write operations.</summary>
[DataContract]
public class WriteItem
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Per-item result from a batch write operation.</summary>
[DataContract]
public class WriteResult
{
/// <summary>Tag address that was written.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Whether the individual write succeeded.</summary>
[DataMember(Order = 2)]
public bool Success { get; set; }
/// <summary>Error message for this item, if any.</summary>
[DataMember(Order = 3)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
[DataContract]
public class WriteBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
}
/// <summary>Response from a batch Write call.</summary>
[DataContract]
public class WriteBatchResponse
{
/// <summary>Overall success — false if any item failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> Results { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// WriteBatchAndWait
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Request to write multiple tag values then poll a flag tag
/// until it matches an expected value or the timeout expires.
/// </summary>
[DataContract]
public class WriteBatchAndWaitRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
/// <summary>Tag to poll after writes complete.</summary>
[DataMember(Order = 3)]
public string FlagTag { get; set; } = string.Empty;
/// <summary>Expected value for the flag tag (string comparison).</summary>
[DataMember(Order = 4)]
public string FlagValue { get; set; } = string.Empty;
/// <summary>Timeout in milliseconds (default 5000 if &lt;= 0).</summary>
[DataMember(Order = 5)]
public int TimeoutMs { get; set; }
/// <summary>Poll interval in milliseconds (default 100 if &lt;= 0).</summary>
[DataMember(Order = 6)]
public int PollIntervalMs { get; set; }
}
/// <summary>Response from a WriteBatchAndWait call.</summary>
[DataContract]
public class WriteBatchAndWaitResponse
{
/// <summary>Overall operation success.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> WriteResults { get; set; } = [];
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
[DataMember(Order = 4)]
public bool FlagReached { get; set; }
/// <summary>Total elapsed time in milliseconds.</summary>
[DataMember(Order = 5)]
public int ElapsedMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Subscribe
// ────────────────────────────────────────────────────────────────
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
[DataContract]
public class SubscribeRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to monitor.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
/// <summary>Backend sampling interval in milliseconds.</summary>
[DataMember(Order = 3)]
public int SamplingMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// CheckApiKey
// ────────────────────────────────────────────────────────────────
/// <summary>Request to validate an API key without creating a session.</summary>
[DataContract]
public class CheckApiKeyRequest
{
/// <summary>API key to validate.</summary>
[DataMember(Order = 1)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from an API key validation check.</summary>
[DataContract]
public class CheckApiKeyResponse
{
/// <summary>Whether the API key is valid.</summary>
[DataMember(Order = 1)]
public bool IsValid { get; set; }
/// <summary>Validation message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
/// <param name="Value">The value.</param>
/// <param name="Timestamp">The timestamp when the value was read.</param>
/// <param name="Quality">The quality of the value.</param>
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
{
/// <summary>Creates a new VTQ with the specified value and quality, using the current UTC timestamp.</summary>
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>Creates a new VTQ with the specified value, timestamp, and quality.</summary>
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>Creates a Good-quality VTQ with the current UTC time.</summary>
public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>Creates a Bad-quality VTQ with the current UTC time.</summary>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>Creates an Uncertain-quality VTQ with the current UTC time.</summary>
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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
/// </summary>
TimeSpan DefaultTimeout { get; set; }
/// <summary>
/// Connects to the LmxProxy service
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Disconnects from the LmxProxy service
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Checks if the client is connected to the service
/// </summary>
Task<bool> IsConnectedAsync();
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
/// <summary>
/// Reads multiple tag values in a single batch
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple tag values in a single batch
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to tag updates
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ISubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> onUpdate, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
Dictionary<string, object> GetMetrics();
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Factory interface for creating LmxProxyClient instances
/// </summary>
public interface ILmxProxyClientFactory
{
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient();
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(string configurationName);
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
/// <summary>
/// Default implementation of ILmxProxyClientFactory
/// </summary>
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the LmxProxyClientFactory
/// </summary>
/// <param name="configuration">Application configuration</param>
public LmxProxyClientFactory(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient()
{
return CreateClient("LmxProxy");
}
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(string configurationName)
{
IConfigurationSection section = _configuration.GetSection(configurationName);
if (!section.GetChildren().Any() && section.Value == null)
{
throw new InvalidOperationException($"Configuration section '{configurationName}' not found");
}
var builder = new LmxProxyClientBuilder();
// Configure from appsettings
string? host = section["Host"];
if (!string.IsNullOrEmpty(host))
{
builder.WithHost(host);
}
if (int.TryParse(section["Port"], out int port))
{
builder.WithPort(port);
}
string? apiKey = section["ApiKey"];
if (!string.IsNullOrEmpty(apiKey))
{
builder.WithApiKey(apiKey);
}
if (TimeSpan.TryParse(section["Timeout"], out TimeSpan timeout))
{
builder.WithTimeout(timeout);
}
// Retry configuration
IConfigurationSection? retrySection = section.GetSection("Retry");
if (retrySection != null && (retrySection.GetChildren().Any() || retrySection.Value != null))
{
if (int.TryParse(retrySection["MaxAttempts"], out int maxAttempts) &&
TimeSpan.TryParse(retrySection["Delay"], out TimeSpan retryDelay))
{
builder.WithRetryPolicy(maxAttempts, retryDelay);
}
}
// SSL configuration
bool useSsl = section.GetValue<bool>("UseSsl");
if (useSsl)
{
string? certificatePath = section["CertificatePath"];
builder.WithSslCredentials(certificatePath);
}
// Metrics configuration
if (section.GetValue<bool>("EnableMetrics"))
{
builder.WithMetrics();
}
// Correlation ID configuration
string? correlationHeader = section["CorrelationIdHeader"];
if (!string.IsNullOrEmpty(correlationHeader))
{
builder.WithCorrelationIdHeader(correlationHeader);
}
// Logger is optional - don't set a default one
return builder.Build();
}
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
{
ArgumentNullException.ThrowIfNull(builderAction);
var builder = new LmxProxyClientBuilder();
builderAction(builder);
// Logger is optional - caller can set it via builderAction if needed
return builder.Build();
}
}
}

View File

@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// API key information returned from CheckApiKey
/// </summary>
public class ApiKeyInfo
{
/// <summary>
/// Whether the API key is valid
/// </summary>
public bool IsValid { get; }
/// <summary>
/// The role assigned to the API key
/// </summary>
public string Role { get; }
/// <summary>
/// Description of the API key
/// </summary>
public string Description { get; }
/// <summary>
/// Initializes a new instance of the ApiKeyInfo class
/// </summary>
/// <param name="isValid">Whether the API key is valid</param>
/// <param name="role">The role assigned to the API key</param>
/// <param name="description">Description of the API key</param>
public ApiKeyInfo(bool isValid, string role, string description)
{
IsValid = isValid;
Role = role ?? string.Empty;
Description = description ?? string.Empty;
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Metrics collection for client operations
/// </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 object _latencyLock = new();
/// <summary>
/// Increments the operation count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementOperationCount(string operation)
{
_operationCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Increments the error count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementErrorCount(string operation)
{
_errorCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Records latency for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
/// <param name="milliseconds">The latency in milliseconds.</param>
public void RecordLatency(string operation, long milliseconds)
{
lock (_latencyLock)
{
if (!_latencies.ContainsKey(operation))
{
_latencies[operation] = [];
}
_latencies[operation].Add(milliseconds);
// Keep only last 1000 entries to prevent memory growth
if (_latencies[operation].Count > 1000)
{
_latencies[operation].RemoveAt(0);
}
}
}
/// <summary>
/// Gets a snapshot of current metrics.
/// </summary>
/// <returns>A dictionary containing metric data.</returns>
public Dictionary<string, object> GetSnapshot()
{
var snapshot = new Dictionary<string, object>();
foreach (KeyValuePair<string, long> kvp in _operationCounts)
{
snapshot[$"{kvp.Key}_count"] = kvp.Value;
}
foreach (KeyValuePair<string, long> kvp in _errorCounts)
{
snapshot[$"{kvp.Key}_errors"] = kvp.Value;
}
lock (_latencyLock)
{
foreach (KeyValuePair<string, List<long>> kvp in _latencies)
{
if (kvp.Value.Any())
{
snapshot[$"{kvp.Key}_avg_latency_ms"] = kvp.Value.Average();
snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(kvp.Value, 95);
snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(kvp.Value, 99);
}
}
}
return snapshot;
}
private double GetPercentile(List<long> values, int percentile)
{
var sorted = values.OrderBy(x => x).ToList();
int index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
return sorted[Math.Max(0, index)];
}
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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 ILogger<LmxProxyClient> _logger;
private readonly Action<ISubscription>? _onDispose;
private readonly CancellationTokenSource _cts = new();
private Task? _processingTask;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the CodeFirstSubscription class.
/// </summary>
/// <param name="client">The gRPC ScadaService client.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="tags">The list of tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="logger">Logger for diagnostic information.</param>
/// <param name="onDispose">Optional callback invoked when the subscription is disposed.</param>
public CodeFirstSubscription(
IScadaService client,
string sessionId,
List<string> tags,
Action<string, Vtq> onUpdate,
ILogger<LmxProxyClient> logger,
Action<ISubscription>? onDispose = null)
{
_client = client;
_sessionId = sessionId;
_tags = tags;
_onUpdate = onUpdate;
_logger = logger;
_onDispose = onDispose;
}
/// <summary>
/// Starts the subscription asynchronously and begins processing tag value updates.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the subscription processing has started.</returns>
public Task StartAsync(CancellationToken cancellationToken = default)
{
_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 vtq in _client.SubscribeAsync(request, linkedCts.Token))
{
try
{
Vtq convertedVtq = ConvertToVtq(vtq.Tag, vtq);
_onUpdate(vtq.Tag, convertedVtq);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtq.Tag);
}
}
}
catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested || cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Subscription cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in subscription processing");
try { await _cts.CancelAsync(); } catch { /* ignore */ }
}
finally
{
if (!_disposed)
{
_disposed = true;
_onDispose?.Invoke(this);
}
}
}
/// <summary>
/// Asynchronously disposes the subscription and stops processing tag updates.
/// </summary>
/// <returns>A task representing the asynchronous disposal operation.</returns>
public async Task DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _cts.CancelAsync();
try
{
if (_processingTask != null)
{
await _processingTask;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
finally
{
_cts.Dispose();
_onDispose?.Invoke(this);
}
}
/// <summary>
/// Synchronously disposes the subscription and stops processing tag updates.
/// </summary>
public void Dispose()
{
if (_disposed) return;
try
{
Task task = DisposeAsync();
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
_logger.LogWarning("Subscription disposal timed out");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during synchronous disposal");
}
}
}
}
}

View File

@@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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
{
/// <summary>
/// Connects to the LmxProxy service and establishes a session
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
GrpcChannel? provisionalChannel = null;
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(LmxProxyClient));
}
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
{
_logger.LogDebug("LmxProxyClient already connected to {Host}:{Port} with session {SessionId}",
_host, _port, _sessionId);
return;
}
string securityMode = _tlsConfiguration?.UseTls == true ? "TLS/SSL" : "INSECURE";
_logger.LogInformation("Creating new {SecurityMode} connection to LmxProxy at {Host}:{Port}",
securityMode, _host, _port);
Uri endpoint = BuildEndpointUri();
provisionalChannel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger);
// Create code-first gRPC client
IScadaService provisionalClient = provisionalChannel.CreateGrpcService<IScadaService>();
// Establish session with the server
var connectRequest = new ConnectRequest
{
ClientId = $"ScadaBridge-{Guid.NewGuid():N}",
ApiKey = _apiKey ?? string.Empty
};
ConnectResponse connectResponse = await provisionalClient.ConnectAsync(connectRequest);
if (!connectResponse.Success)
{
provisionalChannel.Dispose();
throw new InvalidOperationException($"Failed to establish session: {connectResponse.Message}");
}
// Dispose any existing channel before replacing it
_channel?.Dispose();
_channel = provisionalChannel;
_client = provisionalClient;
_sessionId = connectResponse.SessionId;
_isConnected = true;
provisionalChannel = null;
StartKeepAlive();
_logger.LogInformation("Successfully connected to LmxProxy with session {SessionId}", _sessionId);
}
catch (Exception ex)
{
_isConnected = false;
_client = null;
_sessionId = string.Empty;
_logger.LogError(ex, "Failed to connect to LmxProxy");
throw;
}
finally
{
provisionalChannel?.Dispose();
_connectionLock.Release();
}
}
private void StartKeepAlive()
{
StopKeepAlive();
_keepAliveTimer = new Timer(async _ =>
{
try
{
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
{
// Send a lightweight ping to keep session alive
var request = new GetConnectionStateRequest { SessionId = _sessionId };
await _client.GetConnectionStateAsync(request);
_logger.LogDebug("Keep-alive ping sent successfully for session {SessionId}", _sessionId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Keep-alive ping failed");
StopKeepAlive();
await MarkDisconnectedAsync(ex).ConfigureAwait(false);
}
}, null, _keepAliveInterval, _keepAliveInterval);
}
private void StopKeepAlive()
{
_keepAliveTimer?.Dispose();
_keepAliveTimer = null;
}
/// <summary>
/// Disconnects from the LmxProxy service
/// </summary>
public async Task DisconnectAsync()
{
await _connectionLock.WaitAsync();
try
{
StopKeepAlive();
if (_client != null && !string.IsNullOrEmpty(_sessionId))
{
try
{
var request = new DisconnectRequest { SessionId = _sessionId };
await _client.DisconnectAsync(request);
_logger.LogInformation("Session {SessionId} disconnected", _sessionId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during disconnect");
}
}
_client = null;
_sessionId = string.Empty;
_isConnected = false;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
}
/// <summary>
/// Connects the LmxProxy to MxAccess (legacy method - session now established in ConnectAsync)
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<(bool Success, string? ErrorMessage)> ConnectToMxAccessAsync(CancellationToken cancellationToken = default)
{
// Session is now established in ConnectAsync
if (IsConnected)
return Task.FromResult((true, (string?)null));
return Task.FromResult<(bool Success, string? ErrorMessage)>((false, "Not connected. Call ConnectAsync first."));
}
/// <summary>
/// Disconnects the LmxProxy from MxAccess (legacy method)
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<(bool Success, string? ErrorMessage)> DisconnectFromMxAccessAsync(CancellationToken cancellationToken = default)
{
try
{
await DisconnectAsync();
return (true, null);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
/// <summary>
/// Gets the connection state of the LmxProxy
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<(bool IsConnected, string? ClientId)> GetConnectionStateAsync(CancellationToken cancellationToken = default)
{
EnsureConnected();
var request = new GetConnectionStateRequest { SessionId = _sessionId };
GetConnectionStateResponse response = await _client!.GetConnectionStateAsync(request);
return (response.IsConnected, response.ClientId);
}
/// <summary>
/// Builds the gRPC endpoint URI (http/https) based on TLS configuration.
/// </summary>
private Uri BuildEndpointUri()
{
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
return new UriBuilder
{
Scheme = scheme,
Host = _host,
Port = _port
}.Uri;
}
private async Task MarkDisconnectedAsync(Exception? ex = null)
{
if (_disposed)
return;
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
_isConnected = false;
_client = null;
_sessionId = string.Empty;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
List<ISubscription> subsToDispose;
lock (_subscriptionLock)
{
subsToDispose = new List<ISubscription>(_activeSubscriptions);
_activeSubscriptions.Clear();
}
foreach (ISubscription sub in subsToDispose)
{
try
{
await sub.DisposeAsync().ConfigureAwait(false);
}
catch (Exception disposeEx)
{
_logger.LogWarning(disposeEx, "Error disposing subscription after disconnect");
}
}
if (ex != null)
{
_logger.LogWarning(ex, "Connection marked disconnected due to keep-alive failure");
}
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Represents a subscription to tag value changes
/// </summary>
public interface ISubscription : IDisposable
{
/// <summary>
/// Disposes the subscription asynchronously
/// </summary>
Task DisposeAsync();
}
}

View File

@@ -0,0 +1,573 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Polly;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Security;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Client for communicating with the LmxProxy gRPC service using protobuf-net.Grpc code-first
/// </summary>
public partial class LmxProxyClient : ILmxProxyClient
{
private static readonly string Http2InsecureSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
private readonly ILogger<LmxProxyClient> _logger;
private readonly string _host;
private readonly int _port;
private readonly string? _apiKey;
private GrpcChannel? _channel;
private IScadaService? _client;
private string _sessionId = string.Empty;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly List<ISubscription> _activeSubscriptions = [];
private readonly Lock _subscriptionLock = new();
private bool _disposed;
private bool _isConnected;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private ClientConfiguration? _configuration;
private IAsyncPolicy? _retryPolicy;
private readonly ClientMetrics _metrics = new();
private Timer? _keepAliveTimer;
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
private readonly ClientTlsConfiguration? _tlsConfiguration;
static LmxProxyClient()
{
AppContext.SetSwitch(Http2InsecureSwitch, true);
}
/// <summary>
/// Gets or sets the default timeout for operations
/// </summary>
public TimeSpan DefaultTimeout
{
get => _defaultTimeout;
set
{
if (value <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive");
if (value > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(value), "Timeout cannot exceed 10 minutes");
_defaultTimeout = value;
}
}
/// <summary>
/// Initializes a new instance of the LmxProxyClient
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey = null, ILogger<LmxProxyClient>? logger = null)
: this(host, port, apiKey, null, logger)
{
}
/// <summary>
/// Creates a new instance of the LmxProxyClient with TLS configuration
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="tlsConfiguration">TLS configuration for secure connections</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger<LmxProxyClient>? logger = null)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_host = host;
_port = port;
_apiKey = apiKey;
_tlsConfiguration = tlsConfiguration;
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
}
/// <summary>
/// Gets whether the client is connected to the service
/// </summary>
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
/// <summary>
/// Asynchronously checks if the client is connected with proper synchronization
/// </summary>
public async Task<bool> IsConnectedAsync()
{
await _connectionLock.WaitAsync();
try
{
return !_disposed && _client != null && _isConnected && !string.IsNullOrEmpty(_sessionId);
}
finally
{
_connectionLock.Release();
}
}
/// <summary>
/// Sets the builder configuration (internal use)
/// </summary>
/// <param name="configuration">The client configuration.</param>
internal void SetBuilderConfiguration(ClientConfiguration configuration)
{
_configuration = configuration;
// Setup retry policy if configured
if (configuration.MaxRetryAttempts > 0)
{
_retryPolicy = Policy
.Handle<Exception>(IsTransientError)
.WaitAndRetryAsync(
configuration.MaxRetryAttempts,
retryAttempt => configuration.RetryDelay * Math.Pow(2, retryAttempt - 1),
onRetry: (exception, timeSpan, retryCount, context) =>
{
object? correlationId = context.GetValueOrDefault("CorrelationId", "N/A");
_logger.LogWarning(exception,
"Retry {RetryCount} after {Delay}ms. CorrelationId: {CorrelationId}",
retryCount, timeSpan.TotalMilliseconds, correlationId);
});
}
}
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
EnsureConnected();
string correlationId = GenerateCorrelationId();
var stopwatch = Stopwatch.StartNew();
try
{
_metrics.IncrementOperationCount("Read");
var request = new ReadRequest
{
SessionId = _sessionId,
Tag = address
};
ReadResponse response = await ExecuteWithRetryAsync(async () =>
await _client!.ReadAsync(request),
correlationId);
if (!response.Success)
{
_metrics.IncrementErrorCount("Read");
throw new InvalidOperationException($"Read failed for tag '{address}': {response.Message}. CorrelationId: {correlationId}");
}
_metrics.RecordLatency("Read", stopwatch.ElapsedMilliseconds);
return ConvertToVtq(address, response.Vtq);
}
catch (Exception ex)
{
_metrics.IncrementErrorCount("Read");
_logger.LogError(ex, "Read operation failed for tag: {Tag}, CorrelationId: {CorrelationId}",
address, correlationId);
throw;
}
}
/// <summary>
/// Reads multiple tag values
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(addresses);
var addressList = addresses.ToList();
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
EnsureConnected();
var request = new ReadBatchRequest
{
SessionId = _sessionId,
Tags = addressList
};
ReadBatchResponse response = await _client!.ReadBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"ReadBatch failed: {response.Message}");
var results = new Dictionary<string, Vtq>();
foreach (VtqMessage vtq in response.Vtqs)
{
results[vtq.Tag] = ConvertToVtq(vtq.Tag, vtq);
}
return results;
}
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
ArgumentNullException.ThrowIfNull(value);
EnsureConnected();
var request = new WriteRequest
{
SessionId = _sessionId,
Tag = address,
Value = ConvertToString(value)
};
WriteResponse response = await _client!.WriteAsync(request);
if (!response.Success)
throw new InvalidOperationException($"Write failed: {response.Message}");
}
/// <summary>
/// Writes multiple tag values
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList()
};
WriteBatchResponse response = await _client!.WriteBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
}
/// <summary>
/// Writes values and waits for a condition to be met
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="flagAddress">The flag address to write.</param>
/// <param name="flagValue">The flag value to write.</param>
/// <param name="responseAddress">The response address to monitor.</param>
/// <param name="responseValue">The expected response value.</param>
/// <param name="timeoutSeconds">Timeout in seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
int timeoutSeconds = 30,
CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchAndWaitRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList(),
FlagTag = flagAddress,
FlagValue = ConvertToString(flagValue),
TimeoutMs = timeoutSeconds * 1000,
PollIntervalMs = 100
};
WriteBatchAndWaitResponse response = await _client!.WriteBatchAndWaitAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatchAndWait failed: {response.Message}");
return response.FlagReached;
}
/// <summary>
/// Checks the validity and permissions of the current API key
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ApiKeyInfo> CheckApiKeyAsync(CancellationToken cancellationToken = default)
{
EnsureConnected();
var request = new CheckApiKeyRequest { ApiKey = _apiKey ?? string.Empty };
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
return new ApiKeyInfo(
response.IsValid,
"ReadWrite", // Code-first contract doesn't return role
response.Message);
}
/// <summary>
/// Subscribes to tag value changes
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ISubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> onUpdate,
CancellationToken cancellationToken = default)
{
List<string> addressList = addresses?.ToList() ?? throw new ArgumentNullException(nameof(addresses));
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
ArgumentNullException.ThrowIfNull(onUpdate);
EnsureConnected();
var subscription = new CodeFirstSubscription(_client!, _sessionId, addressList, onUpdate, _logger, RemoveSubscription);
// Track the subscription
lock (_subscriptionLock)
{
_activeSubscriptions.Add(subscription);
}
// Start processing updates
Task startTask = subscription.StartAsync(cancellationToken);
// Log any startup errors but don't throw
startTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
_logger.LogError(t.Exception, "Subscription startup failed");
}
}, TaskContinuationOptions.OnlyOnFaulted);
return Task.FromResult<ISubscription>(subscription);
}
private void EnsureConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(LmxProxyClient));
if (_client == null || !_isConnected || string.IsNullOrEmpty(_sessionId))
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
}
private static Vtq ConvertToVtq(string tag, VtqMessage? vtqMessage)
{
if (vtqMessage == null)
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Parse the string value
object? value = vtqMessage.Value;
if (!string.IsNullOrEmpty(vtqMessage.Value))
{
// Try to parse as numeric types
if (double.TryParse(vtqMessage.Value, out double doubleVal))
value = doubleVal;
else if (bool.TryParse(vtqMessage.Value, out bool boolVal))
value = boolVal;
else
value = vtqMessage.Value;
}
var timestamp = new DateTime(vtqMessage.TimestampUtcTicks, DateTimeKind.Utc);
Quality quality = vtqMessage.Quality?.ToUpperInvariant() switch
{
"GOOD" => Quality.Good,
"UNCERTAIN" => Quality.Uncertain,
_ => Quality.Bad
};
return new Vtq(value, timestamp, quality);
}
private static string ConvertToString(object value)
{
if (value == null)
return string.Empty;
return value switch
{
DateTime dt => dt.ToUniversalTime().ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
bool b => b.ToString().ToLowerInvariant(),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Removes a subscription from the active tracking list
/// </summary>
private void RemoveSubscription(ISubscription subscription)
{
lock (_subscriptionLock)
{
_activeSubscriptions.Remove(subscription);
}
}
/// <summary>
/// Disposes of the client and closes the connection
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().AsTask().GetAwaiter().GetResult();
GC.SuppressFinalize(this);
}
/// <summary>
/// Asynchronously disposes of the client and closes the connection
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
await DisposeCoreAsync().ConfigureAwait(false);
_connectionLock.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected disposal implementation
/// </summary>
/// <param name="disposing">True if disposing managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing || _disposed)
return;
_disposed = true;
DisposeCoreAsync().GetAwaiter().GetResult();
_connectionLock.Dispose();
}
private async Task DisposeCoreAsync()
{
StopKeepAlive();
List<ISubscription> subscriptionsToDispose;
lock (_subscriptionLock)
{
subscriptionsToDispose = new List<ISubscription>(_activeSubscriptions);
_activeSubscriptions.Clear();
}
foreach (ISubscription subscription in subscriptionsToDispose)
{
try
{
await subscription.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
}
// Disconnect session
if (_client != null && !string.IsNullOrEmpty(_sessionId))
{
try
{
var request = new DisconnectRequest { SessionId = _sessionId };
await _client.DisconnectAsync(request);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error during disconnect on dispose");
}
}
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
_client = null;
_sessionId = string.Empty;
_isConnected = false;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
}
private string GenerateCorrelationId()
{
return Guid.NewGuid().ToString("N");
}
private bool IsTransientError(Exception ex)
{
// Check for transient gRPC errors
return ex.Message.Contains("Unavailable") ||
ex.Message.Contains("DeadlineExceeded") ||
ex.Message.Contains("ResourceExhausted") ||
ex.Message.Contains("Aborted");
}
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, string correlationId)
{
if (_retryPolicy != null)
{
var context = new Context { ["CorrelationId"] = correlationId };
return await _retryPolicy.ExecuteAsync(async (_) => await operation(), context);
}
return await operation();
}
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
}
}

View File

@@ -0,0 +1,241 @@
using System;
using System.IO;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Builder for creating configured instances of LmxProxyClient
/// </summary>
public class LmxProxyClientBuilder
{
private string? _host;
private int _port = 5050;
private string? _apiKey;
private ILogger<LmxProxyClient>? _logger;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private int _maxRetryAttempts = 3;
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
private bool _enableMetrics;
private string? _correlationIdHeader;
private ClientTlsConfiguration? _tlsConfiguration;
/// <summary>
/// Sets the host address for the LmxProxy service
/// </summary>
/// <param name="host">The host address</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
_host = host;
return this;
}
/// <summary>
/// Sets the port for the LmxProxy service
/// </summary>
/// <param name="port">The port number</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithPort(int port)
{
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_port = port;
return this;
}
/// <summary>
/// Sets the API key for authentication
/// </summary>
/// <param name="apiKey">The API key</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithApiKey(string apiKey)
{
_apiKey = apiKey;
return this;
}
/// <summary>
/// Sets the logger instance
/// </summary>
/// <param name="logger">The logger</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
return this;
}
/// <summary>
/// Sets the default timeout for operations
/// </summary>
/// <param name="timeout">The timeout duration</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive");
if (timeout > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout cannot exceed 10 minutes");
_defaultTimeout = timeout;
return this;
}
/// <summary>
/// Enables SSL/TLS with the specified certificate
/// </summary>
/// <param name="certificatePath">Path to the certificate file</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath = null)
{
_tlsConfiguration ??= new ClientTlsConfiguration();
_tlsConfiguration.UseTls = true;
_tlsConfiguration.ServerCaCertificatePath = string.IsNullOrWhiteSpace(certificatePath) ? null : certificatePath;
return this;
}
/// <summary>
/// Applies a full TLS configuration to the client.
/// </summary>
/// <param name="configuration">The TLS configuration to apply.</param>
/// <returns>The builder instance for method chaining.</returns>
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration configuration)
{
_tlsConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration));
return this;
}
/// <summary>
/// Sets the retry configuration
/// </summary>
/// <param name="maxAttempts">Maximum number of retry attempts</param>
/// <param name="retryDelay">Delay between retries</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
{
if (maxAttempts <= 0)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max attempts must be positive");
if (retryDelay <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be positive");
_maxRetryAttempts = maxAttempts;
_retryDelay = retryDelay;
return this;
}
/// <summary>
/// Enables metrics collection
/// </summary>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithMetrics()
{
_enableMetrics = true;
return this;
}
/// <summary>
/// Sets the correlation ID header name for request tracing
/// </summary>
/// <param name="headerName">The header name for correlation ID</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
{
if (string.IsNullOrEmpty(headerName))
throw new ArgumentException("Header name cannot be null or empty", nameof(headerName));
_correlationIdHeader = headerName;
return this;
}
/// <summary>
/// Builds the configured LmxProxyClient instance
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient Build()
{
if (string.IsNullOrWhiteSpace(_host))
throw new InvalidOperationException("Host must be specified");
ValidateTlsConfiguration();
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
{
DefaultTimeout = _defaultTimeout
};
// Store additional configuration for future use
client.SetBuilderConfiguration(new ClientConfiguration
{
MaxRetryAttempts = _maxRetryAttempts,
RetryDelay = _retryDelay,
EnableMetrics = _enableMetrics,
CorrelationIdHeader = _correlationIdHeader
});
return client;
}
private void ValidateTlsConfiguration()
{
if (_tlsConfiguration?.UseTls != true)
{
return;
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ServerCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
{
throw new FileNotFoundException(
$"Certificate file not found: {_tlsConfiguration.ServerCaCertificatePath}",
_tlsConfiguration.ServerCaCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCertificatePath))
{
throw new FileNotFoundException(
$"Client certificate file not found: {_tlsConfiguration.ClientCertificatePath}",
_tlsConfiguration.ClientCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientKeyPath) &&
!File.Exists(_tlsConfiguration.ClientKeyPath))
{
throw new FileNotFoundException(
$"Client key file not found: {_tlsConfiguration.ClientKeyPath}",
_tlsConfiguration.ClientKeyPath);
}
}
}
/// <summary>
/// Internal configuration class for storing builder settings
/// </summary>
internal class ClientConfiguration
{
/// <summary>
/// Gets or sets the maximum number of retry attempts.
/// </summary>
public int MaxRetryAttempts { get; set; }
/// <summary>
/// Gets or sets the retry delay.
/// </summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether metrics are enabled.
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name.
/// </summary>
public string? CorrelationIdHeader { get; set; }
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
// Expose internal members to test assembly
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]

View File

@@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
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;
internal static class GrpcChannelFactory
{
private const string Http2UnencryptedSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
static GrpcChannelFactory()
{
AppContext.SetSwitch(Http2UnencryptedSwitch, true);
}
/// <summary>
/// Creates a gRPC channel with optional TLS configuration.
/// </summary>
/// <param name="address">The server address.</param>
/// <param name="tlsConfiguration">Optional TLS configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>A configured gRPC channel.</returns>
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var options = new GrpcChannelOptions
{
HttpHandler = CreateHttpHandler(tlsConfiguration, logger)
};
return GrpcChannel.ForAddress(address, options);
}
private static HttpMessageHandler CreateHttpHandler(ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
EnableMultipleHttp2Connections = true
};
if (tlsConfiguration?.UseTls == true)
{
ConfigureTls(handler, tlsConfiguration, logger);
}
return handler;
}
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tlsConfiguration, ILogger logger)
{
SslClientAuthenticationOptions sslOptions = handler.SslOptions;
sslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
sslOptions.TargetHost = tlsConfiguration.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ClientCertificatePath) &&
!string.IsNullOrWhiteSpace(tlsConfiguration.ClientKeyPath))
{
try
{
var clientCertificate = X509Certificate2.CreateFromPemFile(
tlsConfiguration.ClientCertificatePath,
tlsConfiguration.ClientKeyPath);
clientCertificate = new X509Certificate2(clientCertificate.Export(X509ContentType.Pfx));
sslOptions.ClientCertificates ??= new X509CertificateCollection();
sslOptions.ClientCertificates.Add(clientCertificate);
logger.LogInformation("Configured client certificate for mutual TLS ({CertificatePath})", tlsConfiguration.ClientCertificatePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load client certificate from {CertificatePath}", tlsConfiguration.ClientCertificatePath);
}
}
sslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificate(tlsConfiguration, logger, certificate, chain, sslPolicyErrors);
}
private static bool ValidateServerCertificate(
ClientTlsConfiguration tlsConfiguration,
ILogger logger,
X509Certificate? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{
if (tlsConfiguration.IgnoreAllCertificateErrors)
{
logger.LogWarning("SECURITY WARNING: Ignoring all certificate validation errors for LmxProxy gRPC connection.");
return true;
}
if (certificate is null)
{
logger.LogWarning("Server certificate was null.");
return false;
}
if (!tlsConfiguration.ValidateServerCertificate)
{
logger.LogWarning("SECURITY WARNING: Server certificate validation disabled for LmxProxy gRPC connection.");
return true;
}
X509Certificate2 certificate2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate);
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
string dnsName = certificate2.GetNameInfo(X509NameType.DnsName, forIssuer: false);
if (!string.Equals(dnsName, tlsConfiguration.ServerNameOverride, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("Server certificate subject '{Subject}' does not match expected host '{ExpectedHost}'",
dnsName, tlsConfiguration.ServerNameOverride);
return false;
}
}
using X509Chain validationChain = chain ?? new X509Chain();
validationChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
validationChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerCaCertificatePath) &&
File.Exists(tlsConfiguration.ServerCaCertificatePath))
{
try
{
X509Certificate2 ca = LoadCertificate(tlsConfiguration.ServerCaCertificatePath);
validationChain.ChainPolicy.CustomTrustStore.Add(ca);
validationChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load CA certificate from {Path}", tlsConfiguration.ServerCaCertificatePath);
}
}
if (tlsConfiguration.AllowSelfSignedCertificates)
{
validationChain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority;
}
bool isValid = validationChain.Build(certificate2);
if (isValid)
{
return true;
}
if (tlsConfiguration.AllowSelfSignedCertificates &&
validationChain.ChainStatus.All(status =>
status.Status == X509ChainStatusFlags.UntrustedRoot ||
status.Status == X509ChainStatusFlags.PartialChain))
{
logger.LogWarning("Accepting self-signed certificate for {Subject}", certificate2.Subject);
return true;
}
string statusMessage = string.Join(", ", validationChain.ChainStatus.Select(s => s.Status));
logger.LogWarning("Server certificate validation failed: {Status}", statusMessage);
return false;
}
private static X509Certificate2 LoadCertificate(string path)
{
try
{
return X509Certificate2.CreateFromPemFile(path);
}
catch
{
return new X509Certificate2(File.ReadAllBytes(path));
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for registering LmxProxyClient with dependency injection
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(this IServiceCollection services, IConfiguration configuration)
{
return services.AddLmxProxyClient(configuration, "LmxProxy");
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with a specific configuration section
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <param name="configurationSection">Name of the configuration section</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration,
string configurationSection)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with default configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configurationSection);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with custom configuration
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with custom configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with scoped lifetime
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddScopedLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a scoped client
services.AddScoped<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient();
});
return services;
}
/// <summary>
/// Adds named LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="name">Name for the client</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddNamedLmxProxyClient(
this IServiceCollection services,
string name,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a keyed singleton
services.AddKeyedSingleton<LmxProxyClient>(name, (provider, _) =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
}
/// <summary>
/// Configuration options for LmxProxyClient
/// </summary>
public class LmxProxyClientOptions
{
/// <summary>
/// Gets or sets the host address
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the port number
/// </summary>
public int Port { get; set; } = 5050;
/// <summary>
/// Gets or sets the API key
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Gets or sets the timeout duration
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets whether to use SSL
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// Gets or sets the certificate path for SSL
/// </summary>
public string? CertificatePath { get; set; }
/// <summary>
/// Gets or sets whether to enable metrics
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name
/// </summary>
public string? CorrelationIdHeader { get; set; }
/// <summary>
/// Gets or sets the retry configuration
/// </summary>
public RetryOptions? Retry { get; set; }
}
/// <summary>
/// Retry configuration options
/// </summary>
public class RetryOptions
{
/// <summary>
/// Gets or sets the maximum number of retry attempts
/// </summary>
public int MaxAttempts { get; set; } = 3;
/// <summary>
/// Gets or sets the delay between retries
/// </summary>
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}
}

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for streaming operations with the LmxProxy client
/// </summary>
public static class StreamingExtensions
{
/// <summary>
/// Reads multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag values</returns>
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int batchSize = 100,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new List<string>(batchSize);
int errorCount = 0;
const int maxConsecutiveErrors = 3;
foreach (string address in addresses)
{
batch.Add(address);
if (batch.Count >= batchSize)
{
bool success = false;
int retries = 0;
const int maxRetries = 2;
while (!success && retries < maxRetries)
{
IDictionary<string, Vtq>? results = null;
Exception? lastException = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
errorCount = 0; // Reset error count on success
success = true;
}
catch (OperationCanceledException)
{
throw; // Don't retry on cancellation
}
catch (Exception ex)
{
lastException = ex;
retries++;
errorCount++;
if (errorCount >= maxConsecutiveErrors)
{
throw new InvalidOperationException(
$"Stream reading failed after {maxConsecutiveErrors} consecutive errors", ex);
}
if (retries >= maxRetries)
{
// Log error and continue with next batch
System.Diagnostics.Debug.WriteLine($"Failed to read batch after {maxRetries} retries: {ex.Message}");
batch.Clear();
break;
}
// Wait before retry with exponential backoff
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, retries - 1)), cancellationToken);
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
batch.Clear();
}
}
}
cancellationToken.ThrowIfCancellationRequested();
}
// Process remaining items
if (batch.Count > 0)
{
IDictionary<string, Vtq>? results = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Log error for final batch but don't throw to allow partial results
System.Diagnostics.Debug.WriteLine($"Failed to read final batch: {ex.Message}");
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
}
}
}
/// <summary>
/// Writes multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="values">The values to write as an async enumerable</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of values written</returns>
public static async Task<int> WriteStreamAsync(
this ILmxProxyClient client,
IAsyncEnumerable<KeyValuePair<string, object>> values,
int batchSize = 100,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(values);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new Dictionary<string, object>(batchSize);
int totalWritten = 0;
await foreach (KeyValuePair<string, object> kvp in values.WithCancellation(cancellationToken))
{
batch[kvp.Key] = kvp.Value;
if (batch.Count >= batchSize)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
batch.Clear();
}
}
// Process remaining items
if (batch.Count > 0)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
}
return totalWritten;
}
/// <summary>
/// Processes tag values in parallel batches for maximum throughput
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="processor">The async function to process each value</param>
/// <param name="maxDegreeOfParallelism">Maximum number of concurrent operations</param>
/// <param name="cancellationToken">Cancellation token</param>
public static async Task ProcessInParallelAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
Func<string, Vtq, Task> processor,
int maxDegreeOfParallelism = 4,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
ArgumentNullException.ThrowIfNull(processor);
if (maxDegreeOfParallelism <= 0)
throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism));
var semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism);
var tasks = new List<Task>();
await foreach (KeyValuePair<string, Vtq> kvp in client.ReadStreamAsync(addresses, cancellationToken: cancellationToken))
{
await semaphore.WaitAsync(cancellationToken);
var task = Task.Run(async () =>
{
try
{
await processor(kvp.Key, kvp.Value);
}
finally
{
semaphore.Release();
}
}, cancellationToken);
tasks.Add(task);
}
await Task.WhenAll(tasks);
}
/// <summary>
/// Subscribes to multiple tags and returns updates as an async stream
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to subscribe to</param>
/// <param name="pollIntervalMs">Poll interval in milliseconds</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag updates</returns>
public static async IAsyncEnumerable<Vtq> SubscribeStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int pollIntervalMs = 1000,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
var updateChannel = System.Threading.Channels.Channel.CreateUnbounded<Vtq>();
// Setup update handler
void OnUpdate(string address, Vtq vtq)
{
updateChannel.Writer.TryWrite(vtq);
}
ISubscription subscription = await client.SubscribeAsync(addresses, OnUpdate, cancellationToken);
try
{
await foreach (Vtq update in updateChannel.Reader.ReadAllAsync(cancellationToken))
{
yield return update;
}
}
finally
{
await subscription.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<Description>gRPC client library for LmxProxy service</Description>
<PlatformTarget>AnyCPU</PlatformTarget>
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="protobuf-net.Grpc" Version="1.2.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Polly" Version="8.5.2" />
</ItemGroup>
</Project>