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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 7–6 = major (00=Bad, 01=Uncertain, 11=Good),
|
||||
/// bits 5–2 = substatus, bits 1–0 = 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <= 0).</summary>
|
||||
[DataMember(Order = 5)]
|
||||
public int TimeoutMs { get; set; }
|
||||
|
||||
/// <summary>Poll interval in milliseconds (default 100 if <= 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Expose internal members to test assembly
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user