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
+
+
+
+
+
+
+
+
+
+