From 0d63fb11059119b919cd6e80f652b94ae5b46ca2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 21 Mar 2026 23:41:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(lmxproxy):=20phase=201=20=E2=80=94=20v2=20?= =?UTF-8?q?protocol=20types=20and=20domain=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- lmxproxy/ZB.MOM.WW.LmxProxy.slnx | 4 + .../ClientTlsConfiguration.cs | 0 .../Domain/ConnectionState.cs | 49 ++ .../Domain/Quality.cs | 118 ++++ .../Domain/QualityExtensions.cs | 8 + .../Domain/ScadaContracts.cs | 444 +++++++++++++++ .../ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs | 27 + .../ILmxProxyClient.cs | 0 .../ILmxProxyClientFactory.cs | 0 .../LmxProxyClient.ApiKeyInfo.cs | 0 .../LmxProxyClient.ClientMetrics.cs | 0 .../LmxProxyClient.CodeFirstSubscription.cs | 0 .../LmxProxyClient.Connection.cs | 0 .../LmxProxyClient.ISubscription.cs | 0 .../LmxProxyClient.cs | 0 .../LmxProxyClientBuilder.cs | 0 .../Properties/AssemblyInfo.cs | 0 .../Security/GrpcChannelFactory.cs | 0 .../ServiceCollectionExtensions.cs | 0 .../StreamingExtensions.cs | 0 .../ZB.MOM.WW.LmxProxy.Client.csproj | 27 + .../ZB.MOM.WW.LmxProxy.Host/App.config | 0 .../Configuration/ConfigurationValidator.cs | 0 .../Configuration/LmxProxyConfiguration.cs | 0 .../ServiceRecoveryConfiguration.cs | 0 .../SubscriptionConfiguration.cs | 0 .../Configuration/TlsConfiguration.cs | 0 .../Domain/ClientStats.cs | 0 .../Domain/ConnectionState.cs | 38 ++ .../Domain/ConnectionStateChangedEventArgs.cs | 45 ++ .../Domain/IScadaClient.cs | 104 ++++ .../ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs | 124 ++++ .../Domain/SubscriptionStats.cs | 0 .../ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs | 129 +++++ .../Grpc/Protos/scada.proto | 166 ++++++ .../Grpc/Services/ScadaGrpcService.cs | 0 .../MxAccessClient.Connection.cs | 0 .../MxAccessClient.EventHandlers.cs | 0 .../MxAccessClient.NestedTypes.cs | 0 .../MxAccessClient.ReadWrite.cs | 0 .../MxAccessClient.Subscription.cs | 0 .../Implementation/MxAccessClient.cs | 0 .../LmxProxyService.cs | 0 .../ZB.MOM.WW.LmxProxy.Host/Program.cs | 87 +++ .../Security/ApiKey.cs | 0 .../Security/ApiKeyConfiguration.cs | 0 .../Security/ApiKeyInterceptor.cs | 0 .../Security/ApiKeyService.cs | 0 .../Security/TlsCertificateManager.cs | 0 .../Services/HealthCheckService.cs | 0 .../Services/PerformanceMetrics.cs | 0 .../Services/RetryPolicies.cs | 0 .../Services/SessionManager.cs | 0 .../Services/StatusReportService.cs | 0 .../Services/StatusWebServer.cs | 0 .../Services/SubscriptionManager.cs | 0 .../ZB.MOM.WW.LmxProxy.Host.csproj | 65 +++ .../appsettings.Production.json | 0 .../ZB.MOM.WW.LmxProxy.Host/appsettings.json | 84 +++ .../appsettings.tls.json | 0 .../Domain/ConnectionState.cs | 51 +- .../Domain/Quality.cs | 149 ++--- .../Domain/QualityExtensions.cs | 12 +- .../Domain/ScadaContracts.cs | 536 ++++++++++-------- .../ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs | 27 +- .../ZB.MOM.WW.LmxProxy.Client.csproj | 3 +- .../Domain/ConnectionState.cs | 23 - .../Domain/ConnectionStateChangedEventArgs.cs | 21 - .../Domain/IScadaClient.cs | 95 +--- .../ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs | 81 +-- .../Domain/QualityCodeMapper.cs | 167 ++++++ .../Domain/QualityExtensions.cs | 17 + .../Domain/TypedValueConverter.cs | 211 +++++++ .../src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs | 85 +-- .../Grpc/Protos/scada.proto | 284 ++++++---- .../src/ZB.MOM.WW.LmxProxy.Host/Program.cs | 83 +-- .../ZB.MOM.WW.LmxProxy.Host.csproj | 47 +- .../ZB.MOM.WW.LmxProxy.Host/appsettings.json | 82 --- .../CrossStackSerializationTests.cs | 270 +++++++++ .../Domain/QualityExtensionsTests.cs | 29 + .../Domain/ScadaContractsTests.cs | 134 +++++ .../Domain/VtqTests.cs | 33 ++ .../ZB.MOM.WW.LmxProxy.Client.Tests.csproj | 36 ++ .../Domain/QualityCodeMapperTests.cs | 87 +++ .../Domain/QualityExtensionsTests.cs | 39 ++ .../Domain/TypedValueConverterTests.cs | 196 +++++++ .../ZB.MOM.WW.LmxProxy.Host.Tests.csproj | 28 + 87 files changed, 3389 insertions(+), 956 deletions(-) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/App.config (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs (100%) rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json (100%) create mode 100644 lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json rename lmxproxy/{src => src-reference}/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json (100%) create mode 100644 lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs create mode 100644 lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs create mode 100644 lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs create mode 100644 lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj diff --git a/lmxproxy/ZB.MOM.WW.LmxProxy.slnx b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx index 427e0a3..4f43016 100644 --- a/lmxproxy/ZB.MOM.WW.LmxProxy.slnx +++ b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx @@ -3,4 +3,8 @@ + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs new file mode 100644 index 0000000..618b417 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs @@ -0,0 +1,49 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// Represents the connection state of an LmxProxy client. +/// +public enum ConnectionState +{ + /// Not connected to the server. + Disconnected, + + /// Connection attempt in progress. + Connecting, + + /// Connected and ready for operations. + Connected, + + /// Graceful disconnect in progress. + Disconnecting, + + /// Connection failed with an error. + Error, + + /// Attempting to re-establish a lost connection. + Reconnecting +} + +/// +/// Event arguments for connection state change notifications. +/// +public class ConnectionStateChangedEventArgs : EventArgs +{ + /// The previous connection state. + public ConnectionState OldState { get; } + + /// The new connection state. + public ConnectionState NewState { get; } + + /// Optional message describing the state change (e.g., error details). + public string? Message { get; } + + public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null) + { + OldState = oldState; + NewState = newState; + Message = message; + } +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs new file mode 100644 index 0000000..1da3084 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs @@ -0,0 +1,118 @@ +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// 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). +/// +public enum Quality : byte +{ + /// Bad – non-specific. + Bad = 0, + + /// Bad – configuration error in the server. + Bad_ConfigError = 4, + + /// Bad – input source is not connected. + Bad_NotConnected = 8, + + /// Bad – device failure detected. + Bad_DeviceFailure = 12, + + /// Bad – sensor failure detected. + Bad_SensorFailure = 16, + + /// Bad – last known value (communication lost, value stale). + Bad_LastKnownValue = 20, + + /// Bad – communication failure. + Bad_CommFailure = 24, + + /// Bad – item is out of service. + Bad_OutOfService = 28, + + /// Uncertain – non-specific. + Uncertain = 64, + + /// Uncertain – non-specific, low limited. + Uncertain_LowLimited = 65, + + /// Uncertain – non-specific, high limited. + Uncertain_HighLimited = 66, + + /// Uncertain – non-specific, constant. + Uncertain_Constant = 67, + + /// Uncertain – last usable value. + Uncertain_LastUsable = 68, + + /// Uncertain – last usable value, low limited. + Uncertain_LastUsable_LL = 69, + + /// Uncertain – last usable value, high limited. + Uncertain_LastUsable_HL = 70, + + /// Uncertain – last usable value, constant. + Uncertain_LastUsable_Cnst = 71, + + /// Uncertain – sensor not accurate. + Uncertain_SensorNotAcc = 80, + + /// Uncertain – sensor not accurate, low limited. + Uncertain_SensorNotAcc_LL = 81, + + /// Uncertain – sensor not accurate, high limited. + Uncertain_SensorNotAcc_HL = 82, + + /// Uncertain – sensor not accurate, constant. + Uncertain_SensorNotAcc_C = 83, + + /// Uncertain – engineering units exceeded. + Uncertain_EuExceeded = 84, + + /// Uncertain – engineering units exceeded, low limited. + Uncertain_EuExceeded_LL = 85, + + /// Uncertain – engineering units exceeded, high limited. + Uncertain_EuExceeded_HL = 86, + + /// Uncertain – engineering units exceeded, constant. + Uncertain_EuExceeded_C = 87, + + /// Uncertain – sub-normal operating conditions. + Uncertain_SubNormal = 88, + + /// Uncertain – sub-normal, low limited. + Uncertain_SubNormal_LL = 89, + + /// Uncertain – sub-normal, high limited. + Uncertain_SubNormal_HL = 90, + + /// Uncertain – sub-normal, constant. + Uncertain_SubNormal_C = 91, + + /// Good – non-specific. + Good = 192, + + /// Good – low limited. + Good_LowLimited = 193, + + /// Good – high limited. + Good_HighLimited = 194, + + /// Good – constant. + Good_Constant = 195, + + /// Good – local override active. + Good_LocalOverride = 216, + + /// Good – local override active, low limited. + Good_LocalOverride_LL = 217, + + /// Good – local override active, high limited. + Good_LocalOverride_HL = 218, + + /// Good – local override active, constant. + Good_LocalOverride_C = 219 +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs new file mode 100644 index 0000000..9f87647 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs @@ -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; +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs new file mode 100644 index 0000000..bebfbc4 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs @@ -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 +// ──────────────────────────────────────────────────────────────── + +/// +/// Code-first gRPC service contract for SCADA operations. +/// +[ServiceContract(Name = "scada.ScadaService")] +public interface IScadaService +{ + /// Establishes a connection with the SCADA service. + ValueTask ConnectAsync(ConnectRequest request); + + /// Terminates a SCADA service connection. + ValueTask DisconnectAsync(DisconnectRequest request); + + /// Retrieves the current state of a SCADA connection. + ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); + + /// Reads a single tag value from the SCADA system. + ValueTask ReadAsync(ReadRequest request); + + /// Reads multiple tag values from the SCADA system in a batch operation. + ValueTask ReadBatchAsync(ReadBatchRequest request); + + /// Writes a single value to a tag in the SCADA system. + ValueTask WriteAsync(WriteRequest request); + + /// Writes multiple values to tags in the SCADA system in a batch operation. + ValueTask WriteBatchAsync(WriteBatchRequest request); + + /// Writes multiple values and waits for a completion flag before returning. + ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); + + /// Subscribes to real-time value changes from specified tags. + IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); + + /// Validates an API key for authentication. + ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); +} + +// ──────────────────────────────────────────────────────────────── +// VTQ message +// ──────────────────────────────────────────────────────────────── + +/// +/// Value-Timestamp-Quality message transmitted over gRPC. +/// All values are string-encoded; timestamps are UTC ticks. +/// +[DataContract] +public class VtqMessage +{ + /// Tag address. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Value encoded as a string. + [DataMember(Order = 2)] + public string Value { get; set; } = string.Empty; + + /// UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01). + [DataMember(Order = 3)] + public long TimestampUtcTicks { get; set; } + + /// Quality string: "Good", "Uncertain", or "Bad". + [DataMember(Order = 4)] + public string Quality { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// Connect +// ──────────────────────────────────────────────────────────────── + +/// Request to establish a session with the proxy server. +[DataContract] +public class ConnectRequest +{ + /// Client identifier (e.g., "ScadaLink-{guid}"). + [DataMember(Order = 1)] + public string ClientId { get; set; } = string.Empty; + + /// API key for authentication (empty if none required). + [DataMember(Order = 2)] + public string ApiKey { get; set; } = string.Empty; +} + +/// Response from a Connect call. +[DataContract] +public class ConnectResponse +{ + /// Whether the connection was established successfully. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Session ID (32-char hex GUID). Only valid when is true. + [DataMember(Order = 3)] + public string SessionId { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// Disconnect +// ──────────────────────────────────────────────────────────────── + +/// Request to terminate a session. +[DataContract] +public class DisconnectRequest +{ + /// Active session ID to disconnect. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +/// Response from a Disconnect call. +[DataContract] +public class DisconnectResponse +{ + /// Whether the disconnect succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// GetConnectionState +// ──────────────────────────────────────────────────────────────── + +/// Request to query connection state for a session. +[DataContract] +public class GetConnectionStateRequest +{ + /// Session ID to query. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +/// Response with connection state information. +[DataContract] +public class GetConnectionStateResponse +{ + /// Whether the session is currently connected. + [DataMember(Order = 1)] + public bool IsConnected { get; set; } + + /// Client identifier for this session. + [DataMember(Order = 2)] + public string ClientId { get; set; } = string.Empty; + + /// UTC ticks when the connection was established. + [DataMember(Order = 3)] + public long ConnectedSinceUtcTicks { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Read +// ──────────────────────────────────────────────────────────────── + +/// Request to read a single tag. +[DataContract] +public class ReadRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag address to read. + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; +} + +/// Response from a single-tag Read call. +[DataContract] +public class ReadResponse +{ + /// Whether the read succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Error message if the read failed. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// The value-timestamp-quality result. + [DataMember(Order = 3)] + public VtqMessage? Vtq { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// ReadBatch +// ──────────────────────────────────────────────────────────────── + +/// Request to read multiple tags in a single round-trip. +[DataContract] +public class ReadBatchRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag addresses to read. + [DataMember(Order = 2)] + public List Tags { get; set; } = []; +} + +/// Response from a batch Read call. +[DataContract] +public class ReadBatchResponse +{ + /// False if any tag read failed. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// VTQ results in the same order as the request tags. + [DataMember(Order = 3)] + public List Vtqs { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// Write +// ──────────────────────────────────────────────────────────────── + +/// Request to write a single tag value. +[DataContract] +public class WriteRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag address to write. + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; + + /// Value as a string (parsed server-side). + [DataMember(Order = 3)] + public string Value { get; set; } = string.Empty; +} + +/// Response from a single-tag Write call. +[DataContract] +public class WriteResponse +{ + /// Whether the write succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// WriteItem / WriteResult +// ──────────────────────────────────────────────────────────────── + +/// A single tag-value pair for batch write operations. +[DataContract] +public class WriteItem +{ + /// Tag address. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Value as a string. + [DataMember(Order = 2)] + public string Value { get; set; } = string.Empty; +} + +/// Per-item result from a batch write operation. +[DataContract] +public class WriteResult +{ + /// Tag address that was written. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Whether the individual write succeeded. + [DataMember(Order = 2)] + public bool Success { get; set; } + + /// Error message for this item, if any. + [DataMember(Order = 3)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// WriteBatch +// ──────────────────────────────────────────────────────────────── + +/// Request to write multiple tag values in a single round-trip. +[DataContract] +public class WriteBatchRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag-value pairs to write. + [DataMember(Order = 2)] + public List Items { get; set; } = []; +} + +/// Response from a batch Write call. +[DataContract] +public class WriteBatchResponse +{ + /// Overall success — false if any item failed. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Per-item write results. + [DataMember(Order = 3)] + public List Results { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// WriteBatchAndWait +// ──────────────────────────────────────────────────────────────── + +/// +/// Request to write multiple tag values then poll a flag tag +/// until it matches an expected value or the timeout expires. +/// +[DataContract] +public class WriteBatchAndWaitRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag-value pairs to write. + [DataMember(Order = 2)] + public List Items { get; set; } = []; + + /// Tag to poll after writes complete. + [DataMember(Order = 3)] + public string FlagTag { get; set; } = string.Empty; + + /// Expected value for the flag tag (string comparison). + [DataMember(Order = 4)] + public string FlagValue { get; set; } = string.Empty; + + /// Timeout in milliseconds (default 5000 if <= 0). + [DataMember(Order = 5)] + public int TimeoutMs { get; set; } + + /// Poll interval in milliseconds (default 100 if <= 0). + [DataMember(Order = 6)] + public int PollIntervalMs { get; set; } +} + +/// Response from a WriteBatchAndWait call. +[DataContract] +public class WriteBatchAndWaitResponse +{ + /// Overall operation success. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Per-item write results. + [DataMember(Order = 3)] + public List WriteResults { get; set; } = []; + + /// Whether the flag tag matched the expected value before timeout. + [DataMember(Order = 4)] + public bool FlagReached { get; set; } + + /// Total elapsed time in milliseconds. + [DataMember(Order = 5)] + public int ElapsedMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Subscribe +// ──────────────────────────────────────────────────────────────── + +/// Request to subscribe to value change notifications on one or more tags. +[DataContract] +public class SubscribeRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag addresses to monitor. + [DataMember(Order = 2)] + public List Tags { get; set; } = []; + + /// Backend sampling interval in milliseconds. + [DataMember(Order = 3)] + public int SamplingMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// CheckApiKey +// ──────────────────────────────────────────────────────────────── + +/// Request to validate an API key without creating a session. +[DataContract] +public class CheckApiKeyRequest +{ + /// API key to validate. + [DataMember(Order = 1)] + public string ApiKey { get; set; } = string.Empty; +} + +/// Response from an API key validation check. +[DataContract] +public class CheckApiKeyResponse +{ + /// Whether the API key is valid. + [DataMember(Order = 1)] + public bool IsValid { get; set; } + + /// Validation message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs new file mode 100644 index 0000000..34b007a --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs @@ -0,0 +1,27 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// Value, Timestamp, and Quality structure for SCADA data. +/// +/// The value. +/// The timestamp when the value was read. +/// The quality of the value. +public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) +{ + /// Creates a new VTQ with the specified value and quality, using the current UTC timestamp. + public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); + + /// Creates a new VTQ with the specified value, timestamp, and quality. + public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); + + /// Creates a Good-quality VTQ with the current UTC time. + public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good); + + /// Creates a Bad-quality VTQ with the current UTC time. + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + + /// Creates an Uncertain-quality VTQ with the current UTC time. + public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain); +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj new file mode 100644 index 0000000..5f65bc3 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + ZB.MOM.WW.LmxProxy.Client + ZB.MOM.WW.LmxProxy.Client + true + true + gRPC client library for LmxProxy service + AnyCPU + AnyCPU + + + + + + + + + + + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/App.config b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/App.config rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs new file mode 100644 index 0000000..ed1e37e --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Represents the state of a SCADA client connection. + /// + public enum ConnectionState + { + /// + /// The client is disconnected. + /// + Disconnected, + + /// + /// The client is in the process of connecting. + /// + Connecting, + + /// + /// The client is connected. + /// + Connected, + + /// + /// The client is in the process of disconnecting. + /// + Disconnecting, + + /// + /// The client encountered an error. + /// + Error, + + /// + /// The client is reconnecting after a connection loss. + /// + Reconnecting + } +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs new file mode 100644 index 0000000..0549b1b --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs @@ -0,0 +1,45 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Event arguments for SCADA client connection state changes. + /// + public class ConnectionStateChangedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The previous connection state. + /// The current connection state. + /// Optional message providing additional information about the state change. + public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, + string? message = null) + { + PreviousState = previousState; + CurrentState = currentState; + Timestamp = DateTime.UtcNow; + Message = message; + } + + /// + /// Gets the previous connection state. + /// + public ConnectionState PreviousState { get; } + + /// + /// Gets the current connection state. + /// + public ConnectionState CurrentState { get; } + + /// + /// Gets the timestamp when the state change occurred. + /// + public DateTime Timestamp { get; } + + /// + /// Gets additional information about the state change, such as error messages. + /// + public string? Message { get; } + } +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs new file mode 100644 index 0000000..1d16dc7 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Interface for SCADA system clients. + /// + public interface IScadaClient : IAsyncDisposable + { + /// + /// Gets the connection status. + /// + bool IsConnected { get; } + + /// + /// Gets the current connection state. + /// + ConnectionState ConnectionState { get; } + + /// + /// Occurs when the connection state changes. + /// + event EventHandler ConnectionStateChanged; + + /// + /// Connects to the SCADA system. + /// + /// Cancellation token. + Task ConnectAsync(CancellationToken ct = default); + + /// + /// Disconnects from the SCADA system. + /// + /// Cancellation token. + Task DisconnectAsync(CancellationToken ct = default); + + /// + /// Reads a single tag value from the SCADA system. + /// + /// The tag address. + /// Cancellation token. + /// The value, timestamp, and quality. + Task ReadAsync(string address, CancellationToken ct = default); + + /// + /// Reads multiple tag values from the SCADA system. + /// + /// The tag addresses. + /// Cancellation token. + /// Dictionary of address to VTQ values. + Task> + ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); + + /// + /// Writes a single tag value to the SCADA system. + /// + /// The tag address. + /// The value to write. + /// Cancellation token. + Task WriteAsync(string address, object value, CancellationToken ct = default); + + /// + /// Writes multiple tag values to the SCADA system. + /// + /// Dictionary of address to value. + /// Cancellation token. + Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); + + /// + /// Writes a batch of tag values and a flag tag, then waits for a response tag to + /// equal the expected value. + /// + /// The regular tag values to write. + /// The address of the flag tag to write. + /// The value to write to the flag tag. + /// The address of the response tag to monitor. + /// The expected value of the response tag. + /// Cancellation token controlling the wait. + /// + /// true if the response value was observed before cancellation; + /// otherwise false. + /// + Task WriteBatchAndWaitAsync( + IReadOnlyDictionary values, + string flagAddress, + object flagValue, + string responseAddress, + object responseValue, + CancellationToken ct = default); + + /// + /// Subscribes to value changes for specified addresses. + /// + /// The tag addresses to monitor. + /// Callback for value changes. + /// Cancellation token. + /// Subscription handle for unsubscribing. + Task SubscribeAsync(IEnumerable addresses, Action callback, + CancellationToken ct = default); + } +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs new file mode 100644 index 0000000..8cd7715 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs @@ -0,0 +1,124 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// OPC quality codes mapped to domain-level values. + /// The byte value matches the low-order byte of the OPC UA StatusCode, + /// so it can be persisted or round-tripped without translation. + /// + public enum Quality : byte + { + // ─────────────── Bad family (0-31) ─────────────── + /// 0x00 – Bad [Non-Specific] + Bad = 0, + + /// 0x01 – Unknown quality value + Unknown = 1, + + /// 0x04 – Bad [Configuration Error] + Bad_ConfigError = 4, + + /// 0x08 – Bad [Not Connected] + Bad_NotConnected = 8, + + /// 0x0C – Bad [Device Failure] + Bad_DeviceFailure = 12, + + /// 0x10 – Bad [Sensor Failure] + Bad_SensorFailure = 16, + + /// 0x14 – Bad [Last Known Value] + Bad_LastKnownValue = 20, + + /// 0x18 – Bad [Communication Failure] + Bad_CommFailure = 24, + + /// 0x1C – Bad [Out of Service] + Bad_OutOfService = 28, + + // ──────────── Uncertain family (64-95) ─────────── + /// 0x40 – Uncertain [Non-Specific] + Uncertain = 64, + + /// 0x41 – Uncertain [Non-Specific] (Low Limited) + Uncertain_LowLimited = 65, + + /// 0x42 – Uncertain [Non-Specific] (High Limited) + Uncertain_HighLimited = 66, + + /// 0x43 – Uncertain [Non-Specific] (Constant) + Uncertain_Constant = 67, + + /// 0x44 – Uncertain [Last Usable] + Uncertain_LastUsable = 68, + + /// 0x45 – Uncertain [Last Usable] (Low Limited) + Uncertain_LastUsable_LL = 69, + + /// 0x46 – Uncertain [Last Usable] (High Limited) + Uncertain_LastUsable_HL = 70, + + /// 0x47 – Uncertain [Last Usable] (Constant) + Uncertain_LastUsable_Cnst = 71, + + /// 0x50 – Uncertain [Sensor Not Accurate] + Uncertain_SensorNotAcc = 80, + + /// 0x51 – Uncertain [Sensor Not Accurate] (Low Limited) + Uncertain_SensorNotAcc_LL = 81, + + /// 0x52 – Uncertain [Sensor Not Accurate] (High Limited) + Uncertain_SensorNotAcc_HL = 82, + + /// 0x53 – Uncertain [Sensor Not Accurate] (Constant) + Uncertain_SensorNotAcc_C = 83, + + /// 0x54 – Uncertain [EU Exceeded] + Uncertain_EuExceeded = 84, + + /// 0x55 – Uncertain [EU Exceeded] (Low Limited) + Uncertain_EuExceeded_LL = 85, + + /// 0x56 – Uncertain [EU Exceeded] (High Limited) + Uncertain_EuExceeded_HL = 86, + + /// 0x57 – Uncertain [EU Exceeded] (Constant) + Uncertain_EuExceeded_C = 87, + + /// 0x58 – Uncertain [Sub-Normal] + Uncertain_SubNormal = 88, + + /// 0x59 – Uncertain [Sub-Normal] (Low Limited) + Uncertain_SubNormal_LL = 89, + + /// 0x5A – Uncertain [Sub-Normal] (High Limited) + Uncertain_SubNormal_HL = 90, + + /// 0x5B – Uncertain [Sub-Normal] (Constant) + Uncertain_SubNormal_C = 91, + + // ─────────────── Good family (192-219) ──────────── + /// 0xC0 – Good [Non-Specific] + Good = 192, + + /// 0xC1 – Good [Non-Specific] (Low Limited) + Good_LowLimited = 193, + + /// 0xC2 – Good [Non-Specific] (High Limited) + Good_HighLimited = 194, + + /// 0xC3 – Good [Non-Specific] (Constant) + Good_Constant = 195, + + /// 0xD8 – Good [Local Override] + Good_LocalOverride = 216, + + /// 0xD9 – Good [Local Override] (Low Limited) + Good_LocalOverride_LL = 217, + + /// 0xDA – Good [Local Override] (High Limited) + Good_LocalOverride_HL = 218, + + /// 0xDB – Good [Local Override] (Constant) + Good_LocalOverride_C = 219 + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs new file mode 100644 index 0000000..7249908 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs @@ -0,0 +1,129 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Value, Timestamp, and Quality structure for SCADA data. + /// + public readonly struct Vtq : IEquatable + { + /// + /// Gets the value. + /// + public object? Value { get; } + + /// + /// Gets the timestamp when the value was read. + /// + public DateTime Timestamp { get; } + + /// + /// Gets the quality of the value. + /// + public Quality Quality { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The value. + /// The timestamp when the value was read. + /// The quality of the value. + public Vtq(object? value, DateTime timestamp, Quality quality) + { + Value = value; + Timestamp = timestamp; + Quality = quality; + } + + /// + /// Creates a new instance with the specified value and quality, using the current UTC timestamp. + /// + /// The value. + /// The quality of the value. + /// A new instance. + public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality); + + /// + /// Creates a new instance with the specified value, timestamp, and quality. + /// + /// The value. + /// The timestamp when the value was read. + /// The quality of the value. + /// A new instance. + public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); + + /// + /// Creates a instance with good quality and the current UTC timestamp. + /// + /// The value. + /// A new instance with good quality. + public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); + + /// + /// Creates a instance with bad quality and the current UTC timestamp. + /// + /// The value. Optional. + /// A new instance with bad quality. + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + + /// + /// Creates a instance with uncertain quality and the current UTC timestamp. + /// + /// The value. + /// A new instance with uncertain quality. + public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// true if the specified is equal to the current ; otherwise, false. + public bool Equals(Vtq other) => + Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current . + /// true if the specified object is equal to the current ; otherwise, false. + public override bool Equals(object obj) => obj is Vtq other && Equals(other); + + /// + /// Returns the hash code for this instance. + /// + /// A 32-bit signed integer hash code. + public override int GetHashCode() + { + unchecked + { + int hashCode = Value != null ? Value.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)Quality; + return hashCode; + } + } + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + public override string ToString() => + $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; + + /// + /// Determines whether two specified instances of are equal. + /// + /// The first to compare. + /// The second to compare. + /// true if left and right are equal; otherwise, false. + public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal. + /// + /// The first to compare. + /// The second to compare. + /// true if left and right are not equal; otherwise, false. + public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); + } +} diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto new file mode 100644 index 0000000..145b684 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc"; + +package scada; + +// The SCADA service definition +service ScadaService { + // Connection management + rpc Connect(ConnectRequest) returns (ConnectResponse); + rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); + rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); + + // Read operations + rpc Read(ReadRequest) returns (ReadResponse); + rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); + + // Write operations + rpc Write(WriteRequest) returns (WriteResponse); + rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); + rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); + + // Subscription operations (server streaming) - now streams VtqMessage directly + rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); + + // Authentication + rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); +} + +// === CONNECTION MESSAGES === + +message ConnectRequest { + string client_id = 1; + string api_key = 2; +} + +message ConnectResponse { + bool success = 1; + string message = 2; + string session_id = 3; +} + +message DisconnectRequest { + string session_id = 1; +} + +message DisconnectResponse { + bool success = 1; + string message = 2; +} + +message GetConnectionStateRequest { + string session_id = 1; +} + +message GetConnectionStateResponse { + bool is_connected = 1; + string client_id = 2; + int64 connected_since_utc_ticks = 3; +} + +// === VTQ MESSAGE === + +message VtqMessage { + string tag = 1; + string value = 2; + int64 timestamp_utc_ticks = 3; + string quality = 4; // "Good", "Uncertain", "Bad" +} + +// === READ MESSAGES === + +message ReadRequest { + string session_id = 1; + string tag = 2; +} + +message ReadResponse { + bool success = 1; + string message = 2; + VtqMessage vtq = 3; +} + +message ReadBatchRequest { + string session_id = 1; + repeated string tags = 2; +} + +message ReadBatchResponse { + bool success = 1; + string message = 2; + repeated VtqMessage vtqs = 3; +} + +// === WRITE MESSAGES === + +message WriteRequest { + string session_id = 1; + string tag = 2; + string value = 3; +} + +message WriteResponse { + bool success = 1; + string message = 2; +} + +message WriteItem { + string tag = 1; + string value = 2; +} + +message WriteResult { + string tag = 1; + bool success = 2; + string message = 3; +} + +message WriteBatchRequest { + string session_id = 1; + repeated WriteItem items = 2; +} + +message WriteBatchResponse { + bool success = 1; + string message = 2; + repeated WriteResult results = 3; +} + +message WriteBatchAndWaitRequest { + string session_id = 1; + repeated WriteItem items = 2; + string flag_tag = 3; + string flag_value = 4; + int32 timeout_ms = 5; + int32 poll_interval_ms = 6; +} + +message WriteBatchAndWaitResponse { + bool success = 1; + string message = 2; + repeated WriteResult write_results = 3; + bool flag_reached = 4; + int32 elapsed_ms = 5; +} + +// === SUBSCRIPTION MESSAGES === + +message SubscribeRequest { + string session_id = 1; + repeated string tags = 2; + int32 sampling_ms = 3; +} + +// Note: Subscribe RPC now streams VtqMessage directly (defined above) + +// === AUTHENTICATION MESSAGES === + +message CheckApiKeyRequest { + string api_key = 1; +} + +message CheckApiKeyResponse { + bool is_valid = 1; + string message = 2; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs new file mode 100644 index 0000000..c057a30 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration; +using Serilog; +using Topshelf; +using ZB.MOM.WW.LmxProxy.Host.Configuration; + +namespace ZB.MOM.WW.LmxProxy.Host +{ + internal class Program + { + private static void Main(string[] args) + { + // Build configuration + IConfigurationRoot? configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", true, true) + .AddEnvironmentVariables() + .Build(); + + // Configure Serilog from appsettings.json + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + + try + { + Log.Information("Starting ZB.MOM.WW.LmxProxy.Host"); + + // Load configuration + var config = new LmxProxyConfiguration(); + configuration.Bind(config); + + // Validate configuration + if (!ConfigurationValidator.ValidateAndLog(config)) + { + Log.Fatal("Configuration validation failed. Please check the configuration and try again."); + Environment.ExitCode = 1; + return; + } + + // Configure and run the Windows service using TopShelf + TopshelfExitCode exitCode = HostFactory.Run(hostConfig => + { + hostConfig.Service(serviceConfig => + { + serviceConfig.ConstructUsing(() => new LmxProxyService(config)); + serviceConfig.WhenStarted(service => service.Start()); + serviceConfig.WhenStopped(service => service.Stop()); + serviceConfig.WhenPaused(service => service.Pause()); + serviceConfig.WhenContinued(service => service.Continue()); + serviceConfig.WhenShutdown(service => service.Shutdown()); + }); + + hostConfig.UseSerilog(Log.Logger); + + hostConfig.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); + hostConfig.SetDisplayName("SCADA Bridge LMX Proxy"); + hostConfig.SetDescription("Provides gRPC access to Archestra MxAccess for SCADA Bridge"); + + hostConfig.StartAutomatically(); + hostConfig.EnableServiceRecovery(recoveryConfig => + { + recoveryConfig.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); + recoveryConfig.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); + recoveryConfig.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); + recoveryConfig.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); + }); + + hostConfig.OnException(ex => { Log.Fatal(ex, "Unhandled exception in service"); }); + }); + + Log.Information("Service exited with code: {ExitCode}", exitCode); + Environment.ExitCode = (int)exitCode; + } + catch (Exception ex) + { + Log.Fatal(ex, "Failed to start service"); + Environment.ExitCode = 1; + } + finally + { + Log.CloseAndFlush(); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj new file mode 100644 index 0000000..59ddbcf --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj @@ -0,0 +1,65 @@ + + + + net48 + Exe + 9.0 + enable + false + ZB.MOM.WW.LmxProxy.Host + ZB.MOM.WW.LmxProxy.Host + + x86 + x86 + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + ..\..\lib\ArchestrA.MXAccess.dll + true + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json diff --git a/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json new file mode 100644 index 0000000..a85b505 --- /dev/null +++ b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json @@ -0,0 +1,84 @@ +{ + "GrpcPort": 50051, + "ApiKeyConfigFile": "apikeys.json", + "Subscription": { + "ChannelCapacity": 1000, + "ChannelFullMode": "DropOldest" + }, + "ServiceRecovery": { + "FirstFailureDelayMinutes": 1, + "SecondFailureDelayMinutes": 5, + "SubsequentFailureDelayMinutes": 10, + "ResetPeriodDays": 1 + }, + "Connection": { + "MonitorIntervalSeconds": 5, + "ConnectionTimeoutSeconds": 30, + "AutoReconnect": true, + "ReadTimeoutSeconds": 5, + "WriteTimeoutSeconds": 5, + "MaxConcurrentOperations": 10 + }, + "PerformanceMetrics": { + "ReportingIntervalSeconds": 60, + "Enabled": true, + "MaxSamplesPerMetric": 1000 + }, + "HealthCheck": { + "Enabled": true, + "TestTagAddress": "TestChannel.TestDevice.TestTag", + "MaxStaleDataMinutes": 5 + }, + "RetryPolicies": { + "ReadRetryCount": 3, + "WriteRetryCount": 3, + "ConnectionRetryCount": 5, + "CircuitBreakerThreshold": 5, + "CircuitBreakerDurationSeconds": 30 + }, + "Tls": { + "Enabled": true, + "ServerCertificatePath": "certs/server.crt", + "ServerKeyPath": "certs/server.key", + "ClientCaCertificatePath": "certs/ca.crt", + "RequireClientCertificate": false, + "CheckCertificateRevocation": false + }, + "WebServer": { + "Enabled": true, + "Port": 8080 + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning", + "Grpc": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/lmxproxy-.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json b/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json similarity index 100% rename from lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json rename to lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs index 618b417..2300961 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs @@ -1,49 +1,12 @@ -using System; - namespace ZB.MOM.WW.LmxProxy.Client.Domain; -/// -/// Represents the connection state of an LmxProxy client. -/// +/// Represents the state of a connection to the LmxProxy service. public enum ConnectionState { - /// Not connected to the server. - Disconnected, - - /// Connection attempt in progress. - Connecting, - - /// Connected and ready for operations. - Connected, - - /// Graceful disconnect in progress. - Disconnecting, - - /// Connection failed with an error. - Error, - - /// Attempting to re-establish a lost connection. - Reconnecting -} - -/// -/// Event arguments for connection state change notifications. -/// -public class ConnectionStateChangedEventArgs : EventArgs -{ - /// The previous connection state. - public ConnectionState OldState { get; } - - /// The new connection state. - public ConnectionState NewState { get; } - - /// Optional message describing the state change (e.g., error details). - public string? Message { get; } - - public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null) - { - OldState = oldState; - NewState = newState; - Message = message; - } + Disconnected, + Connecting, + Connected, + Disconnecting, + Error, + Reconnecting } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs index 1da3084..2272224 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs @@ -2,117 +2,50 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain; /// /// 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). +/// Byte value matches OPC DA quality low byte for direct round-trip. /// public enum Quality : byte { - /// Bad – non-specific. - Bad = 0, + // ─────────────── Bad family (0-31) ─────────────── + Bad = 0, + Bad_ConfigError = 4, + Bad_NotConnected = 8, + Bad_DeviceFailure = 12, + Bad_SensorFailure = 16, + Bad_LastKnownValue = 20, + Bad_CommFailure = 24, + Bad_OutOfService = 28, + Bad_WaitingForInitialData = 32, - /// Bad – configuration error in the server. - Bad_ConfigError = 4, + // ──────────── Uncertain family (64-95) ─────────── + Uncertain = 64, + Uncertain_LowLimited = 65, + Uncertain_HighLimited = 66, + Uncertain_Constant = 67, + Uncertain_LastUsable = 68, + Uncertain_LastUsable_LL = 69, + Uncertain_LastUsable_HL = 70, + Uncertain_LastUsable_Cnst = 71, + Uncertain_SensorNotAcc = 80, + Uncertain_SensorNotAcc_LL = 81, + Uncertain_SensorNotAcc_HL = 82, + Uncertain_SensorNotAcc_C = 83, + Uncertain_EuExceeded = 84, + Uncertain_EuExceeded_LL = 85, + Uncertain_EuExceeded_HL = 86, + Uncertain_EuExceeded_C = 87, + Uncertain_SubNormal = 88, + Uncertain_SubNormal_LL = 89, + Uncertain_SubNormal_HL = 90, + Uncertain_SubNormal_C = 91, - /// Bad – input source is not connected. - Bad_NotConnected = 8, - - /// Bad – device failure detected. - Bad_DeviceFailure = 12, - - /// Bad – sensor failure detected. - Bad_SensorFailure = 16, - - /// Bad – last known value (communication lost, value stale). - Bad_LastKnownValue = 20, - - /// Bad – communication failure. - Bad_CommFailure = 24, - - /// Bad – item is out of service. - Bad_OutOfService = 28, - - /// Uncertain – non-specific. - Uncertain = 64, - - /// Uncertain – non-specific, low limited. - Uncertain_LowLimited = 65, - - /// Uncertain – non-specific, high limited. - Uncertain_HighLimited = 66, - - /// Uncertain – non-specific, constant. - Uncertain_Constant = 67, - - /// Uncertain – last usable value. - Uncertain_LastUsable = 68, - - /// Uncertain – last usable value, low limited. - Uncertain_LastUsable_LL = 69, - - /// Uncertain – last usable value, high limited. - Uncertain_LastUsable_HL = 70, - - /// Uncertain – last usable value, constant. - Uncertain_LastUsable_Cnst = 71, - - /// Uncertain – sensor not accurate. - Uncertain_SensorNotAcc = 80, - - /// Uncertain – sensor not accurate, low limited. - Uncertain_SensorNotAcc_LL = 81, - - /// Uncertain – sensor not accurate, high limited. - Uncertain_SensorNotAcc_HL = 82, - - /// Uncertain – sensor not accurate, constant. - Uncertain_SensorNotAcc_C = 83, - - /// Uncertain – engineering units exceeded. - Uncertain_EuExceeded = 84, - - /// Uncertain – engineering units exceeded, low limited. - Uncertain_EuExceeded_LL = 85, - - /// Uncertain – engineering units exceeded, high limited. - Uncertain_EuExceeded_HL = 86, - - /// Uncertain – engineering units exceeded, constant. - Uncertain_EuExceeded_C = 87, - - /// Uncertain – sub-normal operating conditions. - Uncertain_SubNormal = 88, - - /// Uncertain – sub-normal, low limited. - Uncertain_SubNormal_LL = 89, - - /// Uncertain – sub-normal, high limited. - Uncertain_SubNormal_HL = 90, - - /// Uncertain – sub-normal, constant. - Uncertain_SubNormal_C = 91, - - /// Good – non-specific. - Good = 192, - - /// Good – low limited. - Good_LowLimited = 193, - - /// Good – high limited. - Good_HighLimited = 194, - - /// Good – constant. - Good_Constant = 195, - - /// Good – local override active. - Good_LocalOverride = 216, - - /// Good – local override active, low limited. - Good_LocalOverride_LL = 217, - - /// Good – local override active, high limited. - Good_LocalOverride_HL = 218, - - /// Good – local override active, constant. - Good_LocalOverride_C = 219 + // ─────────────── Good family (192-219) ──────────── + Good = 192, + Good_LowLimited = 193, + Good_HighLimited = 194, + Good_Constant = 195, + Good_LocalOverride = 216, + Good_LocalOverride_LL = 217, + Good_LocalOverride_HL = 218, + Good_LocalOverride_C = 219 } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs index 9f87647..bc28210 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs @@ -1,8 +1,14 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain; +/// Extension methods for . 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; + /// Returns true if quality is in the Good family (byte >= 192). + public static bool IsGood(this Quality q) => (byte)q >= 192; + + /// Returns true if quality is in the Uncertain family (byte 64-127). + public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128; + + /// Returns true if quality is in the Bad family (byte < 64). + public static bool IsBad(this Quality q) => (byte)q < 64; } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs index bebfbc4..a2f443d 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs @@ -10,435 +10,481 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain; // Service contract // ──────────────────────────────────────────────────────────────── -/// -/// Code-first gRPC service contract for SCADA operations. -/// [ServiceContract(Name = "scada.ScadaService")] public interface IScadaService { - /// Establishes a connection with the SCADA service. - ValueTask ConnectAsync(ConnectRequest request); - - /// Terminates a SCADA service connection. - ValueTask DisconnectAsync(DisconnectRequest request); - - /// Retrieves the current state of a SCADA connection. - ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); - - /// Reads a single tag value from the SCADA system. - ValueTask ReadAsync(ReadRequest request); - - /// Reads multiple tag values from the SCADA system in a batch operation. - ValueTask ReadBatchAsync(ReadBatchRequest request); - - /// Writes a single value to a tag in the SCADA system. - ValueTask WriteAsync(WriteRequest request); - - /// Writes multiple values to tags in the SCADA system in a batch operation. - ValueTask WriteBatchAsync(WriteBatchRequest request); - - /// Writes multiple values and waits for a completion flag before returning. - ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); - - /// Subscribes to real-time value changes from specified tags. - IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); - - /// Validates an API key for authentication. - ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); + ValueTask ConnectAsync(ConnectRequest request); + ValueTask DisconnectAsync(DisconnectRequest request); + ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); + ValueTask ReadAsync(ReadRequest request); + ValueTask ReadBatchAsync(ReadBatchRequest request); + ValueTask WriteAsync(WriteRequest request); + ValueTask WriteBatchAsync(WriteBatchRequest request); + ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); + IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); + ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); } // ──────────────────────────────────────────────────────────────── -// VTQ message +// Typed Value System (v2) // ──────────────────────────────────────────────────────────────── /// -/// Value-Timestamp-Quality message transmitted over gRPC. -/// All values are string-encoded; timestamps are UTC ticks. +/// Carries a value in its native type via a protobuf oneof. +/// Exactly one property will be non-default. All-default = null value. +/// protobuf-net uses the first non-default field in field-number order for oneof. /// +[DataContract] +public class TypedValue +{ + [DataMember(Order = 1)] + public bool BoolValue { get; set; } + + [DataMember(Order = 2)] + public int Int32Value { get; set; } + + [DataMember(Order = 3)] + public long Int64Value { get; set; } + + [DataMember(Order = 4)] + public float FloatValue { get; set; } + + [DataMember(Order = 5)] + public double DoubleValue { get; set; } + + [DataMember(Order = 6)] + public string? StringValue { get; set; } + + [DataMember(Order = 7)] + public byte[]? BytesValue { get; set; } + + [DataMember(Order = 8)] + public long DatetimeValue { get; set; } + + [DataMember(Order = 9)] + public ArrayValue? ArrayValue { get; set; } + + /// + /// Indicates which oneof case is set. Determined by checking non-default values. + /// This is NOT a wire field -- it's a convenience helper. + /// + public TypedValueCase GetValueCase() + { + // Check in reverse priority order to handle protobuf oneof semantics. + // For the oneof, only one should be set at a time. + if (ArrayValue != null) return TypedValueCase.ArrayValue; + if (DatetimeValue != 0) return TypedValueCase.DatetimeValue; + if (BytesValue != null) return TypedValueCase.BytesValue; + if (StringValue != null) return TypedValueCase.StringValue; + if (DoubleValue != 0d) return TypedValueCase.DoubleValue; + if (FloatValue != 0f) return TypedValueCase.FloatValue; + if (Int64Value != 0) return TypedValueCase.Int64Value; + if (Int32Value != 0) return TypedValueCase.Int32Value; + if (BoolValue) return TypedValueCase.BoolValue; + return TypedValueCase.None; + } +} + +/// Identifies which field in TypedValue is set. +public enum TypedValueCase +{ + None = 0, + BoolValue = 1, + Int32Value = 2, + Int64Value = 3, + FloatValue = 4, + DoubleValue = 5, + StringValue = 6, + BytesValue = 7, + DatetimeValue = 8, + ArrayValue = 9 +} + +/// Container for typed arrays. Exactly one field will be set. +[DataContract] +public class ArrayValue +{ + [DataMember(Order = 1)] + public BoolArray? BoolValues { get; set; } + + [DataMember(Order = 2)] + public Int32Array? Int32Values { get; set; } + + [DataMember(Order = 3)] + public Int64Array? Int64Values { get; set; } + + [DataMember(Order = 4)] + public FloatArray? FloatValues { get; set; } + + [DataMember(Order = 5)] + public DoubleArray? DoubleValues { get; set; } + + [DataMember(Order = 6)] + public StringArray? StringValues { get; set; } +} + +[DataContract] +public class BoolArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class Int32Array +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class Int64Array +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class FloatArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class DoubleArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class StringArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// Quality Code (v2) +// ──────────────────────────────────────────────────────────────── + +/// +/// OPC UA-style quality code with numeric status code and symbolic name. +/// +[DataContract] +public class QualityCode +{ + [DataMember(Order = 1)] + public uint StatusCode { get; set; } + + [DataMember(Order = 2)] + public string SymbolicName { get; set; } = string.Empty; + + /// Returns true if quality category is Good (high bits 0x00). + public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000; + + /// Returns true if quality category is Uncertain (high bits 0x40). + public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000; + + /// Returns true if quality category is Bad (high bits 0x80). + public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000; +} + +// ──────────────────────────────────────────────────────────────── +// VTQ message (v2) +// ──────────────────────────────────────────────────────────────── + [DataContract] public class VtqMessage { - /// Tag address. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; - /// Value encoded as a string. - [DataMember(Order = 2)] - public string Value { get; set; } = string.Empty; + [DataMember(Order = 2)] + public TypedValue? Value { get; set; } - /// UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01). - [DataMember(Order = 3)] - public long TimestampUtcTicks { get; set; } + [DataMember(Order = 3)] + public long TimestampUtcTicks { get; set; } - /// Quality string: "Good", "Uncertain", or "Bad". - [DataMember(Order = 4)] - public string Quality { get; set; } = string.Empty; + [DataMember(Order = 4)] + public QualityCode? Quality { get; set; } } // ──────────────────────────────────────────────────────────────── // Connect // ──────────────────────────────────────────────────────────────── -/// Request to establish a session with the proxy server. [DataContract] public class ConnectRequest { - /// Client identifier (e.g., "ScadaLink-{guid}"). - [DataMember(Order = 1)] - public string ClientId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string ClientId { get; set; } = string.Empty; - /// API key for authentication (empty if none required). - [DataMember(Order = 2)] - public string ApiKey { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string ApiKey { get; set; } = string.Empty; } -/// Response from a Connect call. [DataContract] public class ConnectResponse { - /// Whether the connection was established successfully. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; - /// Session ID (32-char hex GUID). Only valid when is true. - [DataMember(Order = 3)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 3)] + public string SessionId { get; set; } = string.Empty; } // ──────────────────────────────────────────────────────────────── // Disconnect // ──────────────────────────────────────────────────────────────── -/// Request to terminate a session. [DataContract] public class DisconnectRequest { - /// Active session ID to disconnect. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; } -/// Response from a Disconnect call. [DataContract] public class DisconnectResponse { - /// Whether the disconnect succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; } // ──────────────────────────────────────────────────────────────── // GetConnectionState // ──────────────────────────────────────────────────────────────── -/// Request to query connection state for a session. [DataContract] public class GetConnectionStateRequest { - /// Session ID to query. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; } -/// Response with connection state information. [DataContract] public class GetConnectionStateResponse { - /// Whether the session is currently connected. - [DataMember(Order = 1)] - public bool IsConnected { get; set; } + [DataMember(Order = 1)] + public bool IsConnected { get; set; } - /// Client identifier for this session. - [DataMember(Order = 2)] - public string ClientId { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string ClientId { get; set; } = string.Empty; - /// UTC ticks when the connection was established. - [DataMember(Order = 3)] - public long ConnectedSinceUtcTicks { get; set; } + [DataMember(Order = 3)] + public long ConnectedSinceUtcTicks { get; set; } } // ──────────────────────────────────────────────────────────────── // Read // ──────────────────────────────────────────────────────────────── -/// Request to read a single tag. [DataContract] public class ReadRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag address to read. - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; } -/// Response from a single-tag Read call. [DataContract] public class ReadResponse { - /// Whether the read succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Error message if the read failed. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; - /// The value-timestamp-quality result. - [DataMember(Order = 3)] - public VtqMessage? Vtq { get; set; } + [DataMember(Order = 3)] + public VtqMessage? Vtq { get; set; } } -// ──────────────────────────────────────────────────────────────── -// ReadBatch -// ──────────────────────────────────────────────────────────────── - -/// Request to read multiple tags in a single round-trip. [DataContract] public class ReadBatchRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag addresses to read. - [DataMember(Order = 2)] - public List Tags { get; set; } = []; + [DataMember(Order = 2)] + public List Tags { get; set; } = []; } -/// Response from a batch Read call. [DataContract] public class ReadBatchResponse { - /// False if any tag read failed. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; - /// VTQ results in the same order as the request tags. - [DataMember(Order = 3)] - public List Vtqs { get; set; } = []; + [DataMember(Order = 3)] + public List Vtqs { get; set; } = []; } // ──────────────────────────────────────────────────────────────── // Write // ──────────────────────────────────────────────────────────────── -/// Request to write a single tag value. [DataContract] public class WriteRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag address to write. - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; - /// Value as a string (parsed server-side). - [DataMember(Order = 3)] - public string Value { get; set; } = string.Empty; + [DataMember(Order = 3)] + public TypedValue? Value { get; set; } } -/// Response from a single-tag Write call. [DataContract] public class WriteResponse { - /// Whether the write succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; } -// ──────────────────────────────────────────────────────────────── -// WriteItem / WriteResult -// ──────────────────────────────────────────────────────────────── - -/// A single tag-value pair for batch write operations. [DataContract] public class WriteItem { - /// Tag address. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; - /// Value as a string. - [DataMember(Order = 2)] - public string Value { get; set; } = string.Empty; + [DataMember(Order = 2)] + public TypedValue? Value { get; set; } } -/// Per-item result from a batch write operation. [DataContract] public class WriteResult { - /// Tag address that was written. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; - /// Whether the individual write succeeded. - [DataMember(Order = 2)] - public bool Success { get; set; } + [DataMember(Order = 2)] + public bool Success { get; set; } - /// Error message for this item, if any. - [DataMember(Order = 3)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 3)] + public string Message { get; set; } = string.Empty; } -// ──────────────────────────────────────────────────────────────── -// WriteBatch -// ──────────────────────────────────────────────────────────────── - -/// Request to write multiple tag values in a single round-trip. [DataContract] public class WriteBatchRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag-value pairs to write. - [DataMember(Order = 2)] - public List Items { get; set; } = []; + [DataMember(Order = 2)] + public List Items { get; set; } = []; } -/// Response from a batch Write call. [DataContract] public class WriteBatchResponse { - /// Overall success — false if any item failed. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; - /// Per-item write results. - [DataMember(Order = 3)] - public List Results { get; set; } = []; + [DataMember(Order = 3)] + public List Results { get; set; } = []; } // ──────────────────────────────────────────────────────────────── // WriteBatchAndWait // ──────────────────────────────────────────────────────────────── -/// -/// Request to write multiple tag values then poll a flag tag -/// until it matches an expected value or the timeout expires. -/// [DataContract] public class WriteBatchAndWaitRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag-value pairs to write. - [DataMember(Order = 2)] - public List Items { get; set; } = []; + [DataMember(Order = 2)] + public List Items { get; set; } = []; - /// Tag to poll after writes complete. - [DataMember(Order = 3)] - public string FlagTag { get; set; } = string.Empty; + [DataMember(Order = 3)] + public string FlagTag { get; set; } = string.Empty; - /// Expected value for the flag tag (string comparison). - [DataMember(Order = 4)] - public string FlagValue { get; set; } = string.Empty; + [DataMember(Order = 4)] + public TypedValue? FlagValue { get; set; } - /// Timeout in milliseconds (default 5000 if <= 0). - [DataMember(Order = 5)] - public int TimeoutMs { get; set; } + [DataMember(Order = 5)] + public int TimeoutMs { get; set; } - /// Poll interval in milliseconds (default 100 if <= 0). - [DataMember(Order = 6)] - public int PollIntervalMs { get; set; } + [DataMember(Order = 6)] + public int PollIntervalMs { get; set; } } -/// Response from a WriteBatchAndWait call. [DataContract] public class WriteBatchAndWaitResponse { - /// Overall operation success. - [DataMember(Order = 1)] - public bool Success { get; set; } + [DataMember(Order = 1)] + public bool Success { get; set; } - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; - /// Per-item write results. - [DataMember(Order = 3)] - public List WriteResults { get; set; } = []; + [DataMember(Order = 3)] + public List WriteResults { get; set; } = []; - /// Whether the flag tag matched the expected value before timeout. - [DataMember(Order = 4)] - public bool FlagReached { get; set; } + [DataMember(Order = 4)] + public bool FlagReached { get; set; } - /// Total elapsed time in milliseconds. - [DataMember(Order = 5)] - public int ElapsedMs { get; set; } + [DataMember(Order = 5)] + public int ElapsedMs { get; set; } } // ──────────────────────────────────────────────────────────────── // Subscribe // ──────────────────────────────────────────────────────────────── -/// Request to subscribe to value change notifications on one or more tags. [DataContract] public class SubscribeRequest { - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; - /// Tag addresses to monitor. - [DataMember(Order = 2)] - public List Tags { get; set; } = []; + [DataMember(Order = 2)] + public List Tags { get; set; } = []; - /// Backend sampling interval in milliseconds. - [DataMember(Order = 3)] - public int SamplingMs { get; set; } + [DataMember(Order = 3)] + public int SamplingMs { get; set; } } // ──────────────────────────────────────────────────────────────── // CheckApiKey // ──────────────────────────────────────────────────────────────── -/// Request to validate an API key without creating a session. [DataContract] public class CheckApiKeyRequest { - /// API key to validate. - [DataMember(Order = 1)] - public string ApiKey { get; set; } = string.Empty; + [DataMember(Order = 1)] + public string ApiKey { get; set; } = string.Empty; } -/// Response from an API key validation check. [DataContract] public class CheckApiKeyResponse { - /// Whether the API key is valid. - [DataMember(Order = 1)] - public bool IsValid { get; set; } + [DataMember(Order = 1)] + public bool IsValid { get; set; } - /// Validation message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs index 34b007a..a8da4a9 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs @@ -1,27 +1,12 @@ -using System; - namespace ZB.MOM.WW.LmxProxy.Client.Domain; -/// -/// Value, Timestamp, and Quality structure for SCADA data. -/// -/// The value. -/// The timestamp when the value was read. -/// The quality of the value. +/// Value, Timestamp, and Quality for SCADA data. public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) { - /// Creates a new VTQ with the specified value and quality, using the current UTC timestamp. - public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); + public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - /// Creates a new VTQ with the specified value, timestamp, and quality. - public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - - /// Creates a Good-quality VTQ with the current UTC time. - public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good); - - /// Creates a Bad-quality VTQ with the current UTC time. - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - - /// Creates an Uncertain-quality VTQ with the current UTC time. - public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain); + public override string ToString() => + $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj index 5f65bc3..cebb7a2 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj @@ -4,11 +4,12 @@ net10.0 latest enable + enable ZB.MOM.WW.LmxProxy.Client ZB.MOM.WW.LmxProxy.Client true true - gRPC client library for LmxProxy service + gRPC client library for LmxProxy SCADA proxy service AnyCPU AnyCPU diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs index ed1e37e..741ee11 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs @@ -5,34 +5,11 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain /// public enum ConnectionState { - /// - /// The client is disconnected. - /// Disconnected, - - /// - /// The client is in the process of connecting. - /// Connecting, - - /// - /// The client is connected. - /// Connected, - - /// - /// The client is in the process of disconnecting. - /// Disconnecting, - - /// - /// The client encountered an error. - /// Error, - - /// - /// The client is reconnecting after a connection loss. - /// Reconnecting } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs index 0549b1b..ace957b 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs @@ -7,12 +7,6 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain /// public class ConnectionStateChangedEventArgs : EventArgs { - /// - /// Initializes a new instance of the class. - /// - /// The previous connection state. - /// The current connection state. - /// Optional message providing additional information about the state change. public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, string? message = null) { @@ -22,24 +16,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain Message = message; } - /// - /// Gets the previous connection state. - /// public ConnectionState PreviousState { get; } - - /// - /// Gets the current connection state. - /// public ConnectionState CurrentState { get; } - - /// - /// Gets the timestamp when the state change occurred. - /// public DateTime Timestamp { get; } - - /// - /// Gets additional information about the state change, such as error messages. - /// public string? Message { get; } } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs index 1d16dc7..56e21b5 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs @@ -6,99 +6,62 @@ using System.Threading.Tasks; namespace ZB.MOM.WW.LmxProxy.Host.Domain { /// - /// Interface for SCADA system clients. + /// Interface for SCADA system clients (MxAccess wrapper). /// public interface IScadaClient : IAsyncDisposable { - /// - /// Gets the connection status. - /// + /// Gets whether the client is connected to MxAccess. bool IsConnected { get; } - /// - /// Gets the current connection state. - /// + /// Gets the current connection state. ConnectionState ConnectionState { get; } - /// - /// Occurs when the connection state changes. - /// + /// Occurs when the connection state changes. event EventHandler ConnectionStateChanged; - /// - /// Connects to the SCADA system. - /// - /// Cancellation token. + /// Connects to MxAccess. Task ConnectAsync(CancellationToken ct = default); - /// - /// Disconnects from the SCADA system. - /// - /// Cancellation token. + /// Disconnects from MxAccess. Task DisconnectAsync(CancellationToken ct = default); - /// - /// Reads a single tag value from the SCADA system. - /// - /// The tag address. - /// Cancellation token. - /// The value, timestamp, and quality. + /// Reads a single tag value. + /// VTQ with typed value. Task ReadAsync(string address, CancellationToken ct = default); - /// - /// Reads multiple tag values from the SCADA system. - /// - /// The tag addresses. - /// Cancellation token. - /// Dictionary of address to VTQ values. - Task> - ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); + /// Reads multiple tag values with semaphore-controlled concurrency. + /// Dictionary of address to VTQ. + Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); - /// - /// Writes a single tag value to the SCADA system. - /// - /// The tag address. - /// The value to write. - /// Cancellation token. + /// Writes a single tag value. Value is a native .NET type (not string). Task WriteAsync(string address, object value, CancellationToken ct = default); - /// - /// Writes multiple tag values to the SCADA system. - /// - /// Dictionary of address to value. - /// Cancellation token. + /// Writes multiple tag values with semaphore-controlled concurrency. Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); /// - /// Writes a batch of tag values and a flag tag, then waits for a response tag to - /// equal the expected value. + /// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires. + /// Returns (writeSuccess, flagReached, elapsedMs). /// - /// The regular tag values to write. - /// The address of the flag tag to write. - /// The value to write to the flag tag. - /// The address of the response tag to monitor. - /// The expected value of the response tag. - /// Cancellation token controlling the wait. - /// - /// true if the response value was observed before cancellation; - /// otherwise false. - /// - Task WriteBatchAndWaitAsync( + /// Tag-value pairs to write. + /// Tag to poll after writes. + /// Expected value (type-aware comparison). + /// Max wait time in milliseconds. + /// Poll interval in milliseconds. + /// Cancellation token. + Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( IReadOnlyDictionary values, - string flagAddress, + string flagTag, object flagValue, - string responseAddress, - object responseValue, + int timeoutMs, + int pollIntervalMs, CancellationToken ct = default); - /// - /// Subscribes to value changes for specified addresses. - /// - /// The tag addresses to monitor. - /// Callback for value changes. - /// Cancellation token. + /// Subscribes to value changes for specified addresses. /// Subscription handle for unsubscribing. - Task SubscribeAsync(IEnumerable addresses, Action callback, + Task SubscribeAsync( + IEnumerable addresses, + Action callback, CancellationToken ct = default); } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs index 8cd7715..dd230d5 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs @@ -2,123 +2,126 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain { /// /// OPC quality codes mapped to domain-level values. - /// The byte value matches the low-order byte of the OPC UA StatusCode, - /// so it can be persisted or round-tripped without translation. + /// The byte value matches the low-order byte of the OPC DA quality code, + /// enabling direct round-trip between the domain enum and the wire OPC DA byte. /// public enum Quality : byte { // ─────────────── Bad family (0-31) ─────────────── - /// 0x00 – Bad [Non-Specific] + /// 0x00 - Bad [Non-Specific] Bad = 0, - /// 0x01 – Unknown quality value + /// 0x01 - Unknown quality value Unknown = 1, - /// 0x04 – Bad [Configuration Error] + /// 0x04 - Bad [Configuration Error] Bad_ConfigError = 4, - /// 0x08 – Bad [Not Connected] + /// 0x08 - Bad [Not Connected] Bad_NotConnected = 8, - /// 0x0C – Bad [Device Failure] + /// 0x0C - Bad [Device Failure] Bad_DeviceFailure = 12, - /// 0x10 – Bad [Sensor Failure] + /// 0x10 - Bad [Sensor Failure] Bad_SensorFailure = 16, - /// 0x14 – Bad [Last Known Value] + /// 0x14 - Bad [Last Known Value] Bad_LastKnownValue = 20, - /// 0x18 – Bad [Communication Failure] + /// 0x18 - Bad [Communication Failure] Bad_CommFailure = 24, - /// 0x1C – Bad [Out of Service] + /// 0x1C - Bad [Out of Service] Bad_OutOfService = 28, + /// 0x20 - Bad [Waiting for Initial Data] + Bad_WaitingForInitialData = 32, + // ──────────── Uncertain family (64-95) ─────────── - /// 0x40 – Uncertain [Non-Specific] + /// 0x40 - Uncertain [Non-Specific] Uncertain = 64, - /// 0x41 – Uncertain [Non-Specific] (Low Limited) + /// 0x41 - Uncertain [Non-Specific] (Low Limited) Uncertain_LowLimited = 65, - /// 0x42 – Uncertain [Non-Specific] (High Limited) + /// 0x42 - Uncertain [Non-Specific] (High Limited) Uncertain_HighLimited = 66, - /// 0x43 – Uncertain [Non-Specific] (Constant) + /// 0x43 - Uncertain [Non-Specific] (Constant) Uncertain_Constant = 67, - /// 0x44 – Uncertain [Last Usable] + /// 0x44 - Uncertain [Last Usable] Uncertain_LastUsable = 68, - /// 0x45 – Uncertain [Last Usable] (Low Limited) + /// 0x45 - Uncertain [Last Usable] (Low Limited) Uncertain_LastUsable_LL = 69, - /// 0x46 – Uncertain [Last Usable] (High Limited) + /// 0x46 - Uncertain [Last Usable] (High Limited) Uncertain_LastUsable_HL = 70, - /// 0x47 – Uncertain [Last Usable] (Constant) + /// 0x47 - Uncertain [Last Usable] (Constant) Uncertain_LastUsable_Cnst = 71, - /// 0x50 – Uncertain [Sensor Not Accurate] + /// 0x50 - Uncertain [Sensor Not Accurate] Uncertain_SensorNotAcc = 80, - /// 0x51 – Uncertain [Sensor Not Accurate] (Low Limited) + /// 0x51 - Uncertain [Sensor Not Accurate] (Low Limited) Uncertain_SensorNotAcc_LL = 81, - /// 0x52 – Uncertain [Sensor Not Accurate] (High Limited) + /// 0x52 - Uncertain [Sensor Not Accurate] (High Limited) Uncertain_SensorNotAcc_HL = 82, - /// 0x53 – Uncertain [Sensor Not Accurate] (Constant) + /// 0x53 - Uncertain [Sensor Not Accurate] (Constant) Uncertain_SensorNotAcc_C = 83, - /// 0x54 – Uncertain [EU Exceeded] + /// 0x54 - Uncertain [EU Exceeded] Uncertain_EuExceeded = 84, - /// 0x55 – Uncertain [EU Exceeded] (Low Limited) + /// 0x55 - Uncertain [EU Exceeded] (Low Limited) Uncertain_EuExceeded_LL = 85, - /// 0x56 – Uncertain [EU Exceeded] (High Limited) + /// 0x56 - Uncertain [EU Exceeded] (High Limited) Uncertain_EuExceeded_HL = 86, - /// 0x57 – Uncertain [EU Exceeded] (Constant) + /// 0x57 - Uncertain [EU Exceeded] (Constant) Uncertain_EuExceeded_C = 87, - /// 0x58 – Uncertain [Sub-Normal] + /// 0x58 - Uncertain [Sub-Normal] Uncertain_SubNormal = 88, - /// 0x59 – Uncertain [Sub-Normal] (Low Limited) + /// 0x59 - Uncertain [Sub-Normal] (Low Limited) Uncertain_SubNormal_LL = 89, - /// 0x5A – Uncertain [Sub-Normal] (High Limited) + /// 0x5A - Uncertain [Sub-Normal] (High Limited) Uncertain_SubNormal_HL = 90, - /// 0x5B – Uncertain [Sub-Normal] (Constant) + /// 0x5B - Uncertain [Sub-Normal] (Constant) Uncertain_SubNormal_C = 91, // ─────────────── Good family (192-219) ──────────── - /// 0xC0 – Good [Non-Specific] + /// 0xC0 - Good [Non-Specific] Good = 192, - /// 0xC1 – Good [Non-Specific] (Low Limited) + /// 0xC1 - Good [Non-Specific] (Low Limited) Good_LowLimited = 193, - /// 0xC2 – Good [Non-Specific] (High Limited) + /// 0xC2 - Good [Non-Specific] (High Limited) Good_HighLimited = 194, - /// 0xC3 – Good [Non-Specific] (Constant) + /// 0xC3 - Good [Non-Specific] (Constant) Good_Constant = 195, - /// 0xD8 – Good [Local Override] + /// 0xD8 - Good [Local Override] Good_LocalOverride = 216, - /// 0xD9 – Good [Local Override] (Low Limited) + /// 0xD9 - Good [Local Override] (Low Limited) Good_LocalOverride_LL = 217, - /// 0xDA – Good [Local Override] (High Limited) + /// 0xDA - Good [Local Override] (High Limited) Good_LocalOverride_HL = 218, - /// 0xDB – Good [Local Override] (Constant) + /// 0xDB - Good [Local Override] (Constant) Good_LocalOverride_C = 219 } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs new file mode 100644 index 0000000..cae3c85 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Maps between the domain enum and proto QualityCode messages. + /// status_code (uint32) is canonical. symbolic_name is derived from a lookup table. + /// + public static class QualityCodeMapper + { + /// OPC UA status code → symbolic name lookup. + private static readonly Dictionary StatusCodeToName = new Dictionary + { + // Good + { 0x00000000, "Good" }, + { 0x00D80000, "GoodLocalOverride" }, + + // Uncertain + { 0x40900000, "UncertainLastUsableValue" }, + { 0x42390000, "UncertainSensorNotAccurate" }, + { 0x40540000, "UncertainEngineeringUnitsExceeded" }, + { 0x40580000, "UncertainSubNormal" }, + + // Bad + { 0x80000000, "Bad" }, + { 0x80040000, "BadConfigurationError" }, + { 0x808A0000, "BadNotConnected" }, + { 0x806B0000, "BadDeviceFailure" }, + { 0x806D0000, "BadSensorFailure" }, + { 0x80050000, "BadCommunicationFailure" }, + { 0x808F0000, "BadOutOfService" }, + { 0x80320000, "BadWaitingForInitialData" }, + }; + + /// Domain Quality enum → OPC UA status code. + private static readonly Dictionary QualityToStatusCode = new Dictionary + { + // Good family + { Quality.Good, 0x00000000 }, + { Quality.Good_LowLimited, 0x00000000 }, + { Quality.Good_HighLimited, 0x00000000 }, + { Quality.Good_Constant, 0x00000000 }, + { Quality.Good_LocalOverride, 0x00D80000 }, + { Quality.Good_LocalOverride_LL, 0x00D80000 }, + { Quality.Good_LocalOverride_HL, 0x00D80000 }, + { Quality.Good_LocalOverride_C, 0x00D80000 }, + + // Uncertain family + { Quality.Uncertain, 0x40900000 }, + { Quality.Uncertain_LowLimited, 0x40900000 }, + { Quality.Uncertain_HighLimited, 0x40900000 }, + { Quality.Uncertain_Constant, 0x40900000 }, + { Quality.Uncertain_LastUsable, 0x40900000 }, + { Quality.Uncertain_LastUsable_LL, 0x40900000 }, + { Quality.Uncertain_LastUsable_HL, 0x40900000 }, + { Quality.Uncertain_LastUsable_Cnst, 0x40900000 }, + { Quality.Uncertain_SensorNotAcc, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_LL, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_HL, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_C, 0x42390000 }, + { Quality.Uncertain_EuExceeded, 0x40540000 }, + { Quality.Uncertain_EuExceeded_LL, 0x40540000 }, + { Quality.Uncertain_EuExceeded_HL, 0x40540000 }, + { Quality.Uncertain_EuExceeded_C, 0x40540000 }, + { Quality.Uncertain_SubNormal, 0x40580000 }, + { Quality.Uncertain_SubNormal_LL, 0x40580000 }, + { Quality.Uncertain_SubNormal_HL, 0x40580000 }, + { Quality.Uncertain_SubNormal_C, 0x40580000 }, + + // Bad family + { Quality.Bad, 0x80000000 }, + { Quality.Unknown, 0x80000000 }, + { Quality.Bad_ConfigError, 0x80040000 }, + { Quality.Bad_NotConnected, 0x808A0000 }, + { Quality.Bad_DeviceFailure, 0x806B0000 }, + { Quality.Bad_SensorFailure, 0x806D0000 }, + { Quality.Bad_LastKnownValue, 0x80050000 }, + { Quality.Bad_CommFailure, 0x80050000 }, + { Quality.Bad_OutOfService, 0x808F0000 }, + { Quality.Bad_WaitingForInitialData, 0x80320000 }, + }; + + /// + /// Converts a domain Quality enum to a proto QualityCode message. + /// + public static Scada.QualityCode ToQualityCode(Quality quality) + { + var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u; + var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad"; + + return new Scada.QualityCode + { + StatusCode = statusCode, + SymbolicName = symbolicName + }; + } + + /// OPC UA status code → primary domain Quality (reverse lookup). + private static readonly Dictionary StatusCodeToQuality = new Dictionary + { + // Good + { 0x00000000, Quality.Good }, + { 0x00D80000, Quality.Good_LocalOverride }, + + // Uncertain — pick the most specific base variant + { 0x40900000, Quality.Uncertain_LastUsable }, + { 0x42390000, Quality.Uncertain_SensorNotAcc }, + { 0x40540000, Quality.Uncertain_EuExceeded }, + { 0x40580000, Quality.Uncertain_SubNormal }, + + // Bad + { 0x80000000, Quality.Bad }, + { 0x80040000, Quality.Bad_ConfigError }, + { 0x808A0000, Quality.Bad_NotConnected }, + { 0x806B0000, Quality.Bad_DeviceFailure }, + { 0x806D0000, Quality.Bad_SensorFailure }, + { 0x80050000, Quality.Bad_CommFailure }, + { 0x808F0000, Quality.Bad_OutOfService }, + { 0x80320000, Quality.Bad_WaitingForInitialData }, + }; + + /// + /// Converts an OPC UA status code (uint32) to a domain Quality enum. + /// Falls back to the nearest category if the specific code is not mapped. + /// + public static Quality FromStatusCode(uint statusCode) + { + if (StatusCodeToQuality.TryGetValue(statusCode, out var quality)) + return quality; + + // Category fallback + uint category = statusCode & 0xC0000000; + if (category == 0x00000000) return Quality.Good; + if (category == 0x40000000) return Quality.Uncertain; + return Quality.Bad; + } + + /// + /// Gets the symbolic name for a status code. + /// + public static string GetSymbolicName(uint statusCode) + { + if (StatusCodeToName.TryGetValue(statusCode, out var name)) + return name; + + uint category = statusCode & 0xC0000000; + if (category == 0x00000000) return "Good"; + if (category == 0x40000000) return "Uncertain"; + return "Bad"; + } + + /// + /// Creates a QualityCode for a specific well-known status. + /// + public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; + public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; + public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" }; + public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }; + public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" }; + public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" }; + public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }; + public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" }; + public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" }; + public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" }; + public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs new file mode 100644 index 0000000..42d4f79 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Extension methods for the enum. + /// + public static class QualityExtensions + { + /// Returns true if quality is in the Good family (byte >= 192). + public static bool IsGood(this Quality q) => (byte)q >= 192; + + /// Returns true if quality is in the Uncertain family (byte 64-127). + public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128; + + /// Returns true if quality is in the Bad family (byte < 64). + public static bool IsBad(this Quality q) => (byte)q < 64; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs new file mode 100644 index 0000000..9c033df --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs @@ -0,0 +1,211 @@ +using System; +using Google.Protobuf; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Converts between COM variant objects (boxed .NET types from MxAccess) + /// and proto-generated messages. + /// + public static class TypedValueConverter + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter)); + + /// + /// Converts a COM variant object to a proto TypedValue. + /// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL. + /// + public static Scada.TypedValue? ToTypedValue(object? value) + { + if (value == null || value is DBNull) + return null; + + switch (value) + { + case bool b: + return new Scada.TypedValue { BoolValue = b }; + + case short s: // VT_I2 → widened to int32 + return new Scada.TypedValue { Int32Value = s }; + + case int i: // VT_I4 + return new Scada.TypedValue { Int32Value = i }; + + case long l: // VT_I8 + return new Scada.TypedValue { Int64Value = l }; + + case ushort us: // VT_UI2 → widened to int32 + return new Scada.TypedValue { Int32Value = us }; + + case uint ui: // VT_UI4 → widened to int64 to avoid sign issues + return new Scada.TypedValue { Int64Value = ui }; + + case ulong ul: // VT_UI8 → int64, truncation risk + if (ul > (ulong)long.MaxValue) + Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul); + return new Scada.TypedValue { Int64Value = (long)ul }; + + case float f: // VT_R4 + return new Scada.TypedValue { FloatValue = f }; + + case double d: // VT_R8 + return new Scada.TypedValue { DoubleValue = d }; + + case string str: // VT_BSTR + return new Scada.TypedValue { StringValue = str }; + + case DateTime dt: // VT_DATE → UTC Ticks + return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks }; + + case decimal dec: // VT_DECIMAL → double (precision loss) + Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec); + return new Scada.TypedValue { DoubleValue = (double)dec }; + + case byte[] bytes: // VT_ARRAY of bytes + return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) }; + + case bool[] boolArr: + { + var arr = new Scada.BoolArray(); + arr.Values.AddRange(boolArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } }; + } + + case int[] intArr: + { + var arr = new Scada.Int32Array(); + arr.Values.AddRange(intArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } }; + } + + case long[] longArr: + { + var arr = new Scada.Int64Array(); + arr.Values.AddRange(longArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } }; + } + + case float[] floatArr: + { + var arr = new Scada.FloatArray(); + arr.Values.AddRange(floatArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } }; + } + + case double[] doubleArr: + { + var arr = new Scada.DoubleArray(); + arr.Values.AddRange(doubleArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } }; + } + + case string[] strArr: + { + var arr = new Scada.StringArray(); + arr.Values.AddRange(strArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } }; + } + + default: + // VT_UNKNOWN or any unrecognized type — ToString() fallback + Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name); + return new Scada.TypedValue { StringValue = value.ToString() }; + } + } + + /// + /// Converts a proto TypedValue back to a boxed .NET object. + /// Returns null for unset oneof (null TypedValue or ValueCase.None). + /// + public static object? FromTypedValue(Scada.TypedValue? typedValue) + { + if (typedValue == null) + return null; + + switch (typedValue.ValueCase) + { + case Scada.TypedValue.ValueOneofCase.BoolValue: + return typedValue.BoolValue; + + case Scada.TypedValue.ValueOneofCase.Int32Value: + return typedValue.Int32Value; + + case Scada.TypedValue.ValueOneofCase.Int64Value: + return typedValue.Int64Value; + + case Scada.TypedValue.ValueOneofCase.FloatValue: + return typedValue.FloatValue; + + case Scada.TypedValue.ValueOneofCase.DoubleValue: + return typedValue.DoubleValue; + + case Scada.TypedValue.ValueOneofCase.StringValue: + return typedValue.StringValue; + + case Scada.TypedValue.ValueOneofCase.BytesValue: + return typedValue.BytesValue.ToByteArray(); + + case Scada.TypedValue.ValueOneofCase.DatetimeValue: + return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc); + + case Scada.TypedValue.ValueOneofCase.ArrayValue: + return FromArrayValue(typedValue.ArrayValue); + + case Scada.TypedValue.ValueOneofCase.None: + default: + return null; + } + } + + private static object? FromArrayValue(Scada.ArrayValue? arrayValue) + { + if (arrayValue == null) + return null; + + switch (arrayValue.ValuesCase) + { + case Scada.ArrayValue.ValuesOneofCase.BoolValues: + return arrayValue.BoolValues?.Values?.Count > 0 + ? ToArray(arrayValue.BoolValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.Int32Values: + return arrayValue.Int32Values?.Values?.Count > 0 + ? ToArray(arrayValue.Int32Values.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.Int64Values: + return arrayValue.Int64Values?.Values?.Count > 0 + ? ToArray(arrayValue.Int64Values.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.FloatValues: + return arrayValue.FloatValues?.Values?.Count > 0 + ? ToArray(arrayValue.FloatValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.DoubleValues: + return arrayValue.DoubleValues?.Values?.Count > 0 + ? ToArray(arrayValue.DoubleValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.StringValues: + return arrayValue.StringValues?.Values?.Count > 0 + ? ToArray(arrayValue.StringValues.Values) + : Array.Empty(); + + default: + return null; + } + } + + private static T[] ToArray(Google.Protobuf.Collections.RepeatedField repeatedField) + { + var result = new T[repeatedField.Count]; + for (int i = 0; i < repeatedField.Count; i++) + result[i] = repeatedField[i]; + return result; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs index 7249908..8da3d68 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs @@ -7,27 +7,15 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain /// public readonly struct Vtq : IEquatable { - /// - /// Gets the value. - /// + /// Gets the value. Null represents an unset/missing value. public object? Value { get; } - /// - /// Gets the timestamp when the value was read. - /// + /// Gets the UTC timestamp when the value was read. public DateTime Timestamp { get; } - /// - /// Gets the quality of the value. - /// + /// Gets the quality of the value. public Quality Quality { get; } - /// - /// Initializes a new instance of the struct. - /// - /// The value. - /// The timestamp when the value was read. - /// The quality of the value. public Vtq(object? value, DateTime timestamp, Quality quality) { Value = value; @@ -35,63 +23,17 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain Quality = quality; } - /// - /// Creates a new instance with the specified value and quality, using the current UTC timestamp. - /// - /// The value. - /// The quality of the value. - /// A new instance. - public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality); - - /// - /// Creates a new instance with the specified value, timestamp, and quality. - /// - /// The value. - /// The timestamp when the value was read. - /// The quality of the value. - /// A new instance. - public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - - /// - /// Creates a instance with good quality and the current UTC timestamp. - /// - /// The value. - /// A new instance with good quality. + public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); + public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - - /// - /// Creates a instance with bad quality and the current UTC timestamp. - /// - /// The value. Optional. - /// A new instance with bad quality. public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - - /// - /// Creates a instance with uncertain quality and the current UTC timestamp. - /// - /// The value. - /// A new instance with uncertain quality. public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - /// - /// Determines whether the specified is equal to the current . - /// - /// The to compare with the current . - /// true if the specified is equal to the current ; otherwise, false. public bool Equals(Vtq other) => Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; - /// - /// Determines whether the specified object is equal to the current . - /// - /// The object to compare with the current . - /// true if the specified object is equal to the current ; otherwise, false. public override bool Equals(object obj) => obj is Vtq other && Equals(other); - /// - /// Returns the hash code for this instance. - /// - /// A 32-bit signed integer hash code. public override int GetHashCode() { unchecked @@ -103,27 +45,10 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain } } - /// - /// Returns a string that represents the current object. - /// - /// A string that represents the current object. public override string ToString() => $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; - /// - /// Determines whether two specified instances of are equal. - /// - /// The first to compare. - /// The second to compare. - /// true if left and right are equal; otherwise, false. public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); - - /// - /// Determines whether two specified instances of are not equal. - /// - /// The first to compare. - /// The second to compare. - /// true if left and right are not equal; otherwise, false. public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto index 145b684..f9f435f 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto @@ -1,42 +1,80 @@ syntax = "proto3"; - -option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc"; - package scada; -// The SCADA service definition +// ============================================================ +// Service Definition +// ============================================================ + service ScadaService { - // Connection management rpc Connect(ConnectRequest) returns (ConnectResponse); rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - - // Read operations rpc Read(ReadRequest) returns (ReadResponse); rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - - // Write operations rpc Write(WriteRequest) returns (WriteResponse); rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - - // Subscription operations (server streaming) - now streams VtqMessage directly rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - - // Authentication rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); } -// === CONNECTION MESSAGES === +// ============================================================ +// Typed Value System +// ============================================================ + +message TypedValue { + oneof value { + bool bool_value = 1; + int32 int32_value = 2; + int64 int64_value = 3; + float float_value = 4; + double double_value = 5; + string string_value = 6; + bytes bytes_value = 7; + int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01) + ArrayValue array_value = 9; + } +} + +message ArrayValue { + oneof values { + BoolArray bool_values = 1; + Int32Array int32_values = 2; + Int64Array int64_values = 3; + FloatArray float_values = 4; + DoubleArray double_values = 5; + StringArray string_values = 6; + } +} + +message BoolArray { repeated bool values = 1; } +message Int32Array { repeated int32 values = 1; } +message Int64Array { repeated int64 values = 1; } +message FloatArray { repeated float values = 1; } +message DoubleArray { repeated double values = 1; } +message StringArray { repeated string values = 1; } + +// ============================================================ +// OPC UA-Style Quality Codes +// ============================================================ + +message QualityCode { + uint32 status_code = 1; + string symbolic_name = 2; +} + +// ============================================================ +// Connection Lifecycle +// ============================================================ message ConnectRequest { string client_id = 1; - string api_key = 2; + string api_key = 2; } message ConnectResponse { - bool success = 1; - string message = 2; + bool success = 1; + string message = 2; string session_id = 3; } @@ -45,7 +83,7 @@ message DisconnectRequest { } message DisconnectResponse { - bool success = 1; + bool success = 1; string message = 2; } @@ -54,113 +92,121 @@ message GetConnectionStateRequest { } message GetConnectionStateResponse { - bool is_connected = 1; - string client_id = 2; - int64 connected_since_utc_ticks = 3; + bool is_connected = 1; + string client_id = 2; + int64 connected_since_utc_ticks = 3; } -// === VTQ MESSAGE === - -message VtqMessage { - string tag = 1; - string value = 2; - int64 timestamp_utc_ticks = 3; - string quality = 4; // "Good", "Uncertain", "Bad" -} - -// === READ MESSAGES === - -message ReadRequest { - string session_id = 1; - string tag = 2; -} - -message ReadResponse { - bool success = 1; - string message = 2; - VtqMessage vtq = 3; -} - -message ReadBatchRequest { - string session_id = 1; - repeated string tags = 2; -} - -message ReadBatchResponse { - bool success = 1; - string message = 2; - repeated VtqMessage vtqs = 3; -} - -// === WRITE MESSAGES === - -message WriteRequest { - string session_id = 1; - string tag = 2; - string value = 3; -} - -message WriteResponse { - bool success = 1; - string message = 2; -} - -message WriteItem { - string tag = 1; - string value = 2; -} - -message WriteResult { - string tag = 1; - bool success = 2; - string message = 3; -} - -message WriteBatchRequest { - string session_id = 1; - repeated WriteItem items = 2; -} - -message WriteBatchResponse { - bool success = 1; - string message = 2; - repeated WriteResult results = 3; -} - -message WriteBatchAndWaitRequest { - string session_id = 1; - repeated WriteItem items = 2; - string flag_tag = 3; - string flag_value = 4; - int32 timeout_ms = 5; - int32 poll_interval_ms = 6; -} - -message WriteBatchAndWaitResponse { - bool success = 1; - string message = 2; - repeated WriteResult write_results = 3; - bool flag_reached = 4; - int32 elapsed_ms = 5; -} - -// === SUBSCRIPTION MESSAGES === - -message SubscribeRequest { - string session_id = 1; - repeated string tags = 2; - int32 sampling_ms = 3; -} - -// Note: Subscribe RPC now streams VtqMessage directly (defined above) - -// === AUTHENTICATION MESSAGES === - message CheckApiKeyRequest { string api_key = 1; } message CheckApiKeyResponse { - bool is_valid = 1; + bool is_valid = 1; + string message = 2; +} + +// ============================================================ +// Value-Timestamp-Quality +// ============================================================ + +message VtqMessage { + string tag = 1; + TypedValue value = 2; + int64 timestamp_utc_ticks = 3; + QualityCode quality = 4; +} + +// ============================================================ +// Read Operations +// ============================================================ + +message ReadRequest { + string session_id = 1; + string tag = 2; +} + +message ReadResponse { + bool success = 1; + string message = 2; + VtqMessage vtq = 3; +} + +message ReadBatchRequest { + string session_id = 1; + repeated string tags = 2; +} + +message ReadBatchResponse { + bool success = 1; + string message = 2; + repeated VtqMessage vtqs = 3; +} + +// ============================================================ +// Write Operations +// ============================================================ + +message WriteRequest { + string session_id = 1; + string tag = 2; + TypedValue value = 3; +} + +message WriteResponse { + bool success = 1; string message = 2; } + +message WriteItem { + string tag = 1; + TypedValue value = 2; +} + +message WriteResult { + string tag = 1; + bool success = 2; + string message = 3; +} + +message WriteBatchRequest { + string session_id = 1; + repeated WriteItem items = 2; +} + +message WriteBatchResponse { + bool success = 1; + string message = 2; + repeated WriteResult results = 3; +} + +// ============================================================ +// WriteBatchAndWait +// ============================================================ + +message WriteBatchAndWaitRequest { + string session_id = 1; + repeated WriteItem items = 2; + string flag_tag = 3; + TypedValue flag_value = 4; + int32 timeout_ms = 5; + int32 poll_interval_ms = 6; +} + +message WriteBatchAndWaitResponse { + bool success = 1; + string message = 2; + repeated WriteResult write_results = 3; + bool flag_reached = 4; + int32 elapsed_ms = 5; +} + +// ============================================================ +// Subscription +// ============================================================ + +message SubscribeRequest { + string session_id = 1; + repeated string tags = 2; + int32 sampling_ms = 3; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs index c057a30..8989326 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs @@ -1,87 +1,10 @@ -using System; -using System.IO; -using Microsoft.Extensions.Configuration; -using Serilog; -using Topshelf; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - namespace ZB.MOM.WW.LmxProxy.Host { - internal class Program + internal static class Program { - private static void Main(string[] args) + static void Main(string[] args) { - // Build configuration - IConfigurationRoot? configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", true, true) - .AddEnvironmentVariables() - .Build(); - - // Configure Serilog from appsettings.json - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .CreateLogger(); - - try - { - Log.Information("Starting ZB.MOM.WW.LmxProxy.Host"); - - // Load configuration - var config = new LmxProxyConfiguration(); - configuration.Bind(config); - - // Validate configuration - if (!ConfigurationValidator.ValidateAndLog(config)) - { - Log.Fatal("Configuration validation failed. Please check the configuration and try again."); - Environment.ExitCode = 1; - return; - } - - // Configure and run the Windows service using TopShelf - TopshelfExitCode exitCode = HostFactory.Run(hostConfig => - { - hostConfig.Service(serviceConfig => - { - serviceConfig.ConstructUsing(() => new LmxProxyService(config)); - serviceConfig.WhenStarted(service => service.Start()); - serviceConfig.WhenStopped(service => service.Stop()); - serviceConfig.WhenPaused(service => service.Pause()); - serviceConfig.WhenContinued(service => service.Continue()); - serviceConfig.WhenShutdown(service => service.Shutdown()); - }); - - hostConfig.UseSerilog(Log.Logger); - - hostConfig.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); - hostConfig.SetDisplayName("SCADA Bridge LMX Proxy"); - hostConfig.SetDescription("Provides gRPC access to Archestra MxAccess for SCADA Bridge"); - - hostConfig.StartAutomatically(); - hostConfig.EnableServiceRecovery(recoveryConfig => - { - recoveryConfig.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); - recoveryConfig.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); - recoveryConfig.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); - recoveryConfig.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); - }); - - hostConfig.OnException(ex => { Log.Fatal(ex, "Unhandled exception in service"); }); - }); - - Log.Information("Service exited with code: {ExitCode}", exitCode); - Environment.ExitCode = (int)exitCode; - } - catch (Exception ex) - { - Log.Fatal(ex, "Failed to start service"); - Environment.ExitCode = 1; - } - finally - { - Log.CloseAndFlush(); - } + // Placeholder - Phase 3 will implement full Topshelf startup. } } } diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj index 59ddbcf..bbf30a3 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj @@ -8,35 +8,37 @@ false ZB.MOM.WW.LmxProxy.Host ZB.MOM.WW.LmxProxy.Host - x86 x86 true - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -47,7 +49,7 @@ - + @@ -57,9 +59,6 @@ PreserveNewest - - PreserveNewest - diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json index a85b505..2c63c08 100644 --- a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json @@ -1,84 +1,2 @@ { - "GrpcPort": 50051, - "ApiKeyConfigFile": "apikeys.json", - "Subscription": { - "ChannelCapacity": 1000, - "ChannelFullMode": "DropOldest" - }, - "ServiceRecovery": { - "FirstFailureDelayMinutes": 1, - "SecondFailureDelayMinutes": 5, - "SubsequentFailureDelayMinutes": 10, - "ResetPeriodDays": 1 - }, - "Connection": { - "MonitorIntervalSeconds": 5, - "ConnectionTimeoutSeconds": 30, - "AutoReconnect": true, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10 - }, - "PerformanceMetrics": { - "ReportingIntervalSeconds": 60, - "Enabled": true, - "MaxSamplesPerMetric": 1000 - }, - "HealthCheck": { - "Enabled": true, - "TestTagAddress": "TestChannel.TestDevice.TestTag", - "MaxStaleDataMinutes": 5 - }, - "RetryPolicies": { - "ReadRetryCount": 3, - "WriteRetryCount": 3, - "ConnectionRetryCount": 5, - "CircuitBreakerThreshold": 5, - "CircuitBreakerDurationSeconds": 30 - }, - "Tls": { - "Enabled": true, - "ServerCertificatePath": "certs/server.crt", - "ServerKeyPath": "certs/server.key", - "ClientCaCertificatePath": "certs/ca.crt", - "RequireClientCertificate": false, - "CheckCertificateRevocation": false - }, - "WebServer": { - "Enabled": true, - "Port": 8080 - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning", - "Grpc": "Information" - } - }, - "WriteTo": [ - { - "Name": "Console", - "Args": { - "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}" - } - } - ], - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ] - } } diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs new file mode 100644 index 0000000..fb3013d --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs @@ -0,0 +1,270 @@ +using System.IO; +using FluentAssertions; +using Google.Protobuf; +using ProtoBuf; +using Xunit; +using ProtoGenerated = Scada; +using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack; + +/// +/// Verifies wire compatibility between Host proto-generated types and Client code-first types. +/// Serializes with one stack, deserializes with the other. +/// +public class CrossStackSerializationTests +{ + // ── Proto-generated → Code-first ────────────────────────── + + [Fact] + public void VtqMessage_ProtoToCodeFirst_BoolValue() + { + // Arrange: proto-generated VtqMessage with bool TypedValue + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Running", + Value = new ProtoGenerated.TypedValue { BoolValue = true }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + // Act: serialize with proto, deserialize with protobuf-net + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + // Assert + codeFirst.Should().NotBeNull(); + codeFirst.Tag.Should().Be("Motor.Running"); + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.BoolValue.Should().BeTrue(); + codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L); + codeFirst.Quality.Should().NotBeNull(); + codeFirst.Quality!.StatusCode.Should().Be(0x00000000u); + codeFirst.Quality.SymbolicName.Should().Be("Good"); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_DoubleValue() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Speed", + Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.DoubleValue.Should().Be(42.5); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_StringValue() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Name", + Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.StringValue.Should().Be("Pump A"); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_Int32Value() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Count", + Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value!.Int32Value.Should().Be(int.MaxValue); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_BadQuality() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Fault", + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u); + codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure"); + codeFirst.Quality.IsBad.Should().BeTrue(); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_NullValue() + { + // No Value field set — represents null + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Optional", + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + // When no oneof is set, the Value object may be null or all-default + // Either way, GetValueCase() should return None + if (codeFirst.Value != null) + codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_FloatArrayValue() + { + var floatArr = new ProtoGenerated.FloatArray(); + floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f }); + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Samples", + Value = new ProtoGenerated.TypedValue + { + ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr } + }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.ArrayValue.Should().NotBeNull(); + codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull(); + codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f }); + } + + // ── Code-first → Proto-generated ────────────────────────── + + [Fact] + public void VtqMessage_CodeFirstToProto_DoubleValue() + { + var codeFirst = new CodeFirst.VtqMessage + { + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 99.9 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + // Serialize with protobuf-net + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var bytes = ms.ToArray(); + + // Deserialize with Google.Protobuf + var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes); + + protoMsg.Tag.Should().Be("Motor.Speed"); + protoMsg.Value.Should().NotBeNull(); + protoMsg.Value.DoubleValue.Should().Be(99.9); + protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L); + protoMsg.Quality.StatusCode.Should().Be(0x00000000u); + } + + [Fact] + public void WriteRequest_CodeFirstToProto() + { + var codeFirst = new CodeFirst.WriteRequest + { + SessionId = "abc123", + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 42.5 } + }; + + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var bytes = ms.ToArray(); + + var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes); + protoMsg.SessionId.Should().Be("abc123"); + protoMsg.Tag.Should().Be("Motor.Speed"); + protoMsg.Value.Should().NotBeNull(); + protoMsg.Value.DoubleValue.Should().Be(42.5); + } + + [Fact] + public void ConnectRequest_RoundTrips() + { + var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" }; + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray()); + protoMsg.ClientId.Should().Be("ScadaLink-1"); + protoMsg.ApiKey.Should().Be("key-123"); + } + + [Fact] + public void ConnectResponse_RoundTrips() + { + var protoMsg = new ProtoGenerated.ConnectResponse + { + Success = true, + Message = "Connected", + SessionId = "abcdef1234567890abcdef1234567890" + }; + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + codeFirst.Success.Should().BeTrue(); + codeFirst.Message.Should().Be("Connected"); + codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890"); + } + + [Fact] + public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue() + { + var codeFirst = new CodeFirst.WriteBatchAndWaitRequest + { + SessionId = "sess1", + FlagTag = "Motor.Done", + FlagValue = new CodeFirst.TypedValue { BoolValue = true }, + TimeoutMs = 5000, + PollIntervalMs = 100, + Items = + { + new CodeFirst.WriteItem + { + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 50.0 } + } + } + }; + + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray()); + + protoMsg.FlagTag.Should().Be("Motor.Done"); + protoMsg.FlagValue.BoolValue.Should().BeTrue(); + protoMsg.TimeoutMs.Should().Be(5000); + protoMsg.PollIntervalMs.Should().Be(100); + protoMsg.Items.Should().HaveCount(1); + protoMsg.Items[0].Tag.Should().Be("Motor.Speed"); + protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0); + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs new file mode 100644 index 0000000..aba3def --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class QualityExtensionsTests +{ + [Theory] + [InlineData(Quality.Good, true)] + [InlineData(Quality.Good_LocalOverride, true)] + [InlineData(Quality.Uncertain, false)] + [InlineData(Quality.Bad, false)] + public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected); + + [Theory] + [InlineData(Quality.Uncertain, true)] + [InlineData(Quality.Uncertain_LastUsable, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Bad, false)] + public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected); + + [Theory] + [InlineData(Quality.Bad, true)] + [InlineData(Quality.Bad_CommFailure, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Uncertain, false)] + public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected); +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs new file mode 100644 index 0000000..7e5c9a8 --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class ScadaContractsTests +{ + [Fact] + public void TypedValue_GetValueCase_Bool() + { + var tv = new TypedValue { BoolValue = true }; + tv.GetValueCase().Should().Be(TypedValueCase.BoolValue); + } + + [Fact] + public void TypedValue_GetValueCase_Int32() + { + var tv = new TypedValue { Int32Value = 42 }; + tv.GetValueCase().Should().Be(TypedValueCase.Int32Value); + } + + [Fact] + public void TypedValue_GetValueCase_Double() + { + var tv = new TypedValue { DoubleValue = 3.14 }; + tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue); + } + + [Fact] + public void TypedValue_GetValueCase_String() + { + var tv = new TypedValue { StringValue = "hello" }; + tv.GetValueCase().Should().Be(TypedValueCase.StringValue); + } + + [Fact] + public void TypedValue_GetValueCase_None_WhenDefault() + { + var tv = new TypedValue(); + tv.GetValueCase().Should().Be(TypedValueCase.None); + } + + [Fact] + public void TypedValue_GetValueCase_Datetime() + { + var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks }; + tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue); + } + + [Fact] + public void TypedValue_GetValueCase_BytesValue() + { + var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } }; + tv.GetValueCase().Should().Be(TypedValueCase.BytesValue); + } + + [Fact] + public void TypedValue_GetValueCase_ArrayValue() + { + var tv = new TypedValue + { + ArrayValue = new ArrayValue + { + FloatValues = new FloatArray { Values = { 1.0f, 2.0f } } + } + }; + tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue); + } + + [Fact] + public void QualityCode_IsGood() + { + var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; + qc.IsGood.Should().BeTrue(); + qc.IsBad.Should().BeFalse(); + qc.IsUncertain.Should().BeFalse(); + } + + [Fact] + public void QualityCode_IsBad() + { + var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; + qc.IsGood.Should().BeFalse(); + qc.IsBad.Should().BeTrue(); + qc.IsUncertain.Should().BeFalse(); + } + + [Fact] + public void QualityCode_IsUncertain() + { + var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; + qc.IsGood.Should().BeFalse(); + qc.IsBad.Should().BeFalse(); + qc.IsUncertain.Should().BeTrue(); + } + + [Fact] + public void VtqMessage_DefaultProperties() + { + var vtq = new VtqMessage(); + vtq.Tag.Should().BeEmpty(); + vtq.Value.Should().BeNull(); + vtq.TimestampUtcTicks.Should().Be(0); + vtq.Quality.Should().BeNull(); + } + + [Fact] + public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue() + { + var req = new WriteBatchAndWaitRequest + { + SessionId = "abc", + FlagTag = "Motor.Done", + FlagValue = new TypedValue { BoolValue = true }, + TimeoutMs = 5000, + PollIntervalMs = 100 + }; + req.FlagValue.Should().NotBeNull(); + req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue); + } + + [Fact] + public void WriteItem_Value_IsTypedValue() + { + var item = new WriteItem + { + Tag = "Motor.Speed", + Value = new TypedValue { DoubleValue = 42.5 } + }; + item.Value.Should().NotBeNull(); + item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue); + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs new file mode 100644 index 0000000..8d1965e --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class VtqTests +{ + [Fact] + public void Good_FactoryMethod() + { + var vtq = Vtq.Good(42.0); + vtq.Value.Should().Be(42.0); + vtq.Quality.Should().Be(Quality.Good); + vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc); + } + + [Fact] + public void Bad_FactoryMethod() + { + var vtq = Vtq.Bad(); + vtq.Value.Should().BeNull(); + vtq.Quality.Should().Be(Quality.Bad); + } + + [Fact] + public void Uncertain_FactoryMethod() + { + var vtq = Vtq.Uncertain("stale"); + vtq.Value.Should().Be("stale"); + vtq.Quality.Should().Be(Quality.Uncertain); + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj new file mode 100644 index 0000000..9e2f765 --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + latest + enable + enable + false + ZB.MOM.WW.LmxProxy.Client.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs new file mode 100644 index 0000000..b6d23bb --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class QualityCodeMapperTests + { + [Theory] + [InlineData(Quality.Good, 0x00000000u, "Good")] + [InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")] + [InlineData(Quality.Bad, 0x80000000u, "Bad")] + [InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")] + [InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")] + [InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")] + [InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")] + [InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")] + [InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")] + [InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")] + [InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")] + [InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")] + [InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")] + [InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")] + public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName) + { + var qc = QualityCodeMapper.ToQualityCode(quality); + qc.StatusCode.Should().Be(expectedStatusCode); + qc.SymbolicName.Should().Be(expectedName); + } + + [Theory] + [InlineData(0x00000000u, Quality.Good)] + [InlineData(0x80000000u, Quality.Bad)] + [InlineData(0x80040000u, Quality.Bad_ConfigError)] + [InlineData(0x806D0000u, Quality.Bad_SensorFailure)] + [InlineData(0x40900000u, Quality.Uncertain_LastUsable)] + public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality) + { + QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality); + } + + [Fact] + public void FromStatusCode_UnknownGoodCode_FallsBackToGood() + { + QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good); + } + + [Fact] + public void FromStatusCode_UnknownBadCode_FallsBackToBad() + { + QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad); + } + + [Fact] + public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain() + { + QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain); + } + + [Theory] + [InlineData(0x00000000u, "Good")] + [InlineData(0x80000000u, "Bad")] + [InlineData(0x806D0000u, "BadSensorFailure")] + [InlineData(0x40900000u, "UncertainLastUsableValue")] + [InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back + public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName) + { + QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName); + } + + [Fact] + public void FactoryMethods_ReturnCorrectCodes() + { + QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u); + QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u); + QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u); + QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u); + QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u); + QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u); + QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u); + QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u); + QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u); + QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u); + QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs new file mode 100644 index 0000000..53b727c --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class QualityExtensionsTests + { + [Theory] + [InlineData(Quality.Good, true)] + [InlineData(Quality.Good_LocalOverride, true)] + [InlineData(Quality.Uncertain, false)] + [InlineData(Quality.Bad, false)] + public void IsGood(Quality q, bool expected) + { + q.IsGood().Should().Be(expected); + } + + [Theory] + [InlineData(Quality.Uncertain, true)] + [InlineData(Quality.Uncertain_LastUsable, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Bad, false)] + public void IsUncertain(Quality q, bool expected) + { + q.IsUncertain().Should().Be(expected); + } + + [Theory] + [InlineData(Quality.Bad, true)] + [InlineData(Quality.Bad_CommFailure, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Uncertain, false)] + public void IsBad(Quality q, bool expected) + { + q.IsBad().Should().Be(expected); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs new file mode 100644 index 0000000..2ed347c --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs @@ -0,0 +1,196 @@ +using System; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class TypedValueConverterTests + { + [Fact] + public void Null_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(null); + tv.Should().BeNull(); + TypedValueConverter.FromTypedValue(null).Should().BeNull(); + } + + [Fact] + public void DBNull_MapsToNull() + { + var tv = TypedValueConverter.ToTypedValue(DBNull.Value); + tv.Should().BeNull(); + } + + [Fact] + public void Bool_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(true); + tv.Should().NotBeNull(); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue); + tv.BoolValue.Should().BeTrue(); + TypedValueConverter.FromTypedValue(tv).Should().Be(true); + + var tvFalse = TypedValueConverter.ToTypedValue(false); + tvFalse!.BoolValue.Should().BeFalse(); + TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false); + } + + [Fact] + public void Short_WidensToInt32() + { + var tv = TypedValueConverter.ToTypedValue((short)42); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(42); + TypedValueConverter.FromTypedValue(tv).Should().Be(42); + } + + [Fact] + public void Int_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(int.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(int.MaxValue); + TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue); + } + + [Fact] + public void Long_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(long.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(long.MaxValue); + TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue); + } + + [Fact] + public void UShort_WidensToInt32() + { + var tv = TypedValueConverter.ToTypedValue((ushort)65535); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(65535); + } + + [Fact] + public void UInt_WidensToInt64() + { + var tv = TypedValueConverter.ToTypedValue(uint.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(uint.MaxValue); + } + + [Fact] + public void ULong_MapsToInt64() + { + var tv = TypedValueConverter.ToTypedValue((ulong)12345678); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(12345678); + } + + [Fact] + public void Float_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(3.14159f); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue); + tv.FloatValue.Should().Be(3.14159f); + TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f); + } + + [Fact] + public void Double_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(2.718281828459045); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); + tv.DoubleValue.Should().Be(2.718281828459045); + TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045); + } + + [Fact] + public void String_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue("Hello World"); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); + tv.StringValue.Should().Be("Hello World"); + TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World"); + } + + [Fact] + public void DateTime_RoundTrips_AsUtcTicks() + { + var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc); + var tv = TypedValueConverter.ToTypedValue(dt); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue); + tv.DatetimeValue.Should().Be(dt.Ticks); + var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!; + result.Kind.Should().Be(DateTimeKind.Utc); + result.Ticks.Should().Be(dt.Ticks); + } + + [Fact] + public void ByteArray_RoundTrips() + { + var bytes = new byte[] { 0x00, 0xFF, 0x42 }; + var tv = TypedValueConverter.ToTypedValue(bytes); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue); + var result = (byte[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(bytes); + } + + [Fact] + public void Decimal_MapsToDouble() + { + var tv = TypedValueConverter.ToTypedValue(123.456m); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); + tv.DoubleValue.Should().BeApproximately(123.456, 0.001); + } + + [Fact] + public void FloatArray_RoundTrips() + { + var arr = new float[] { 1.0f, 2.0f, 3.0f }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (float[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void IntArray_RoundTrips() + { + var arr = new int[] { 10, 20, 30 }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (int[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void StringArray_RoundTrips() + { + var arr = new string[] { "a", "b", "c" }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (string[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void DoubleArray_RoundTrips() + { + var arr = new double[] { 1.1, 2.2, 3.3 }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (double[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void UnrecognizedType_FallsBackToString() + { + var guid = Guid.NewGuid(); + var tv = TypedValueConverter.ToTypedValue(guid); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); + tv.StringValue.Should().Be(guid.ToString()); + } + } +} diff --git a/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj new file mode 100644 index 0000000..52cce04 --- /dev/null +++ b/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj @@ -0,0 +1,28 @@ + + + + net48 + 9.0 + enable + false + ZB.MOM.WW.LmxProxy.Host.Tests + x86 + x86 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + +