feat(lmxproxy): phase 1 — v2 protocol types and domain model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:41:56 -04:00
parent 08d2a07d8b
commit 0d63fb1105
87 changed files with 3389 additions and 956 deletions

View File

@@ -3,4 +3,8 @@
<Project Path="src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
<Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj" />
</Folder>
</Solution>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Represents the state of a SCADA client connection.
/// </summary>
public enum ConnectionState
{
/// <summary>
/// The client is disconnected.
/// </summary>
Disconnected,
/// <summary>
/// The client is in the process of connecting.
/// </summary>
Connecting,
/// <summary>
/// The client is connected.
/// </summary>
Connected,
/// <summary>
/// The client is in the process of disconnecting.
/// </summary>
Disconnecting,
/// <summary>
/// The client encountered an error.
/// </summary>
Error,
/// <summary>
/// The client is reconnecting after a connection loss.
/// </summary>
Reconnecting
}
}

View File

@@ -0,0 +1,45 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Event arguments for SCADA client connection state changes.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previousState">The previous connection state.</param>
/// <param name="currentState">The current connection state.</param>
/// <param name="message">Optional message providing additional information about the state change.</param>
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
string? message = null)
{
PreviousState = previousState;
CurrentState = currentState;
Timestamp = DateTime.UtcNow;
Message = message;
}
/// <summary>
/// Gets the previous connection state.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets the timestamp when the state change occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets additional information about the state change, such as error messages.
/// </summary>
public string? Message { get; }
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Interface for SCADA system clients.
/// </summary>
public interface IScadaClient : IAsyncDisposable
{
/// <summary>
/// Gets the connection status.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
ConnectionState ConnectionState { get; }
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
/// <summary>
/// Connects to the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Disconnects from the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The value, timestamp, and quality.</returns>
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="addresses">The tag addresses.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary of address to VTQ values.</returns>
Task<IReadOnlyDictionary<string, Vtq>>
ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken ct = default);
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="values">Dictionary of address to value.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
/// <summary>
/// Writes a batch of tag values and a flag tag, then waits for a response tag to
/// equal the expected value.
/// </summary>
/// <param name="values">The regular tag values to write.</param>
/// <param name="flagAddress">The address of the flag tag to write.</param>
/// <param name="flagValue">The value to write to the flag tag.</param>
/// <param name="responseAddress">The address of the response tag to monitor.</param>
/// <param name="responseValue">The expected value of the response tag.</param>
/// <param name="ct">Cancellation token controlling the wait.</param>
/// <returns>
/// <c>true</c> if the response value was observed before cancellation;
/// otherwise <c>false</c>.
/// </returns>
Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
CancellationToken ct = default);
/// <summary>
/// Subscribes to value changes for specified addresses.
/// </summary>
/// <param name="addresses">The tag addresses to monitor.</param>
/// <param name="callback">Callback for value changes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Subscription handle for unsubscribing.</returns>
Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,124 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// 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.
/// </summary>
public enum Quality : byte
{
// ─────────────── Bad family (0-31) ───────────────
/// <summary>0x00 Bad [Non-Specific]</summary>
Bad = 0,
/// <summary>0x01 Unknown quality value</summary>
Unknown = 1,
/// <summary>0x04 Bad [Configuration Error]</summary>
Bad_ConfigError = 4,
/// <summary>0x08 Bad [Not Connected]</summary>
Bad_NotConnected = 8,
/// <summary>0x0C Bad [Device Failure]</summary>
Bad_DeviceFailure = 12,
/// <summary>0x10 Bad [Sensor Failure]</summary>
Bad_SensorFailure = 16,
/// <summary>0x14 Bad [Last Known Value]</summary>
Bad_LastKnownValue = 20,
/// <summary>0x18 Bad [Communication Failure]</summary>
Bad_CommFailure = 24,
/// <summary>0x1C Bad [Out of Service]</summary>
Bad_OutOfService = 28,
// ──────────── Uncertain family (64-95) ───────────
/// <summary>0x40 Uncertain [Non-Specific]</summary>
Uncertain = 64,
/// <summary>0x41 Uncertain [Non-Specific] (Low Limited)</summary>
Uncertain_LowLimited = 65,
/// <summary>0x42 Uncertain [Non-Specific] (High Limited)</summary>
Uncertain_HighLimited = 66,
/// <summary>0x43 Uncertain [Non-Specific] (Constant)</summary>
Uncertain_Constant = 67,
/// <summary>0x44 Uncertain [Last Usable]</summary>
Uncertain_LastUsable = 68,
/// <summary>0x45 Uncertain [Last Usable] (Low Limited)</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>0x46 Uncertain [Last Usable] (High Limited)</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>0x47 Uncertain [Last Usable] (Constant)</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>0x50 Uncertain [Sensor Not Accurate]</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>0x51 Uncertain [Sensor Not Accurate] (Low Limited)</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>0x52 Uncertain [Sensor Not Accurate] (High Limited)</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>0x53 Uncertain [Sensor Not Accurate] (Constant)</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>0x54 Uncertain [EU Exceeded]</summary>
Uncertain_EuExceeded = 84,
/// <summary>0x55 Uncertain [EU Exceeded] (Low Limited)</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>0x56 Uncertain [EU Exceeded] (High Limited)</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>0x57 Uncertain [EU Exceeded] (Constant)</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>0x58 Uncertain [Sub-Normal]</summary>
Uncertain_SubNormal = 88,
/// <summary>0x59 Uncertain [Sub-Normal] (Low Limited)</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>0x5A Uncertain [Sub-Normal] (High Limited)</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>0x5B Uncertain [Sub-Normal] (Constant)</summary>
Uncertain_SubNormal_C = 91,
// ─────────────── Good family (192-219) ────────────
/// <summary>0xC0 Good [Non-Specific]</summary>
Good = 192,
/// <summary>0xC1 Good [Non-Specific] (Low Limited)</summary>
Good_LowLimited = 193,
/// <summary>0xC2 Good [Non-Specific] (High Limited)</summary>
Good_HighLimited = 194,
/// <summary>0xC3 Good [Non-Specific] (Constant)</summary>
Good_Constant = 195,
/// <summary>0xD8 Good [Local Override]</summary>
Good_LocalOverride = 216,
/// <summary>0xD9 Good [Local Override] (Low Limited)</summary>
Good_LocalOverride_LL = 217,
/// <summary>0xDA Good [Local Override] (High Limited)</summary>
Good_LocalOverride_HL = 218,
/// <summary>0xDB Good [Local Override] (Constant)</summary>
Good_LocalOverride_C = 219
}
}

View File

@@ -0,0 +1,129 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the value.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp when the value was read.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality of the value.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
Timestamp = timestamp;
Quality = quality;
}
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value and quality, using the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value, timestamp, and quality.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with good quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with good quality.</returns>
public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with bad quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value. Optional.</param>
/// <returns>A new <see cref="Vtq" /> instance with bad quality.</returns>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with uncertain quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with uncertain quality.</returns>
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Determines whether the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="other">The <see cref="Vtq" /> to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
/// <summary>
/// Determines whether the specified object is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="obj">The object to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified object is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public override bool Equals(object obj) => obj is Vtq other && Equals(other);
/// <summary>
/// Returns the hash code for this instance.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode()
{
unchecked
{
int hashCode = Value != null ? Value.GetHashCode() : 0;
hashCode = (hashCode * 397) ^ Timestamp.GetHashCode();
hashCode = (hashCode * 397) ^ (int)Quality;
return hashCode;
}
}
/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString() =>
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are equal; otherwise, false.</returns>
public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are not equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are not equal; otherwise, false.</returns>
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
}
}

View File

@@ -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;
}

View File

@@ -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<LmxProxyService>(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();
}
}
}
}

View File

@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>Exe</OutputType>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
<!-- Force x86 architecture for all configurations (required by ArchestrA.MXAccess) -->
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core" Version="2.46.6"/>
<PackageReference Include="Grpc.Tools" Version="2.51.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.21.12"/>
<PackageReference Include="Topshelf" Version="4.3.0"/>
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
<PackageReference Include="Serilog" Version="2.10.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0"/>
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0"/>
<PackageReference Include="System.Threading.Channels" Version="4.7.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32"/>
<PackageReference Include="Polly" Version="7.2.4"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1"/>
</ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MXAccess">
<HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both"/>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="App.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -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"
]
}
}

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>Extension methods for <see cref="Quality"/>.</summary>
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;
/// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
public static bool IsGood(this Quality q) => (byte)q >= 192;
/// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
/// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
public static bool IsBad(this Quality q) => (byte)q < 64;
}

View File

@@ -10,435 +10,481 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain;
// Service contract
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Code-first gRPC service contract for SCADA operations.
/// </summary>
[ServiceContract(Name = "scada.ScadaService")]
public interface IScadaService
{
/// <summary>Establishes a connection with the SCADA service.</summary>
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
/// <summary>Terminates a SCADA service connection.</summary>
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
/// <summary>Retrieves the current state of a SCADA connection.</summary>
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
/// <summary>Reads a single tag value from the SCADA system.</summary>
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
/// <summary>Writes multiple values to tags in the SCADA system in a batch operation.</summary>
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
/// <summary>Validates an API key for authentication.</summary>
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
}
// ────────────────────────────────────────────────────────────────
// VTQ message
// Typed Value System (v2)
// ────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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; }
/// <summary>
/// Indicates which oneof case is set. Determined by checking non-default values.
/// This is NOT a wire field -- it's a convenience helper.
/// </summary>
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;
}
}
/// <summary>Identifies which field in TypedValue is set.</summary>
public enum TypedValueCase
{
None = 0,
BoolValue = 1,
Int32Value = 2,
Int64Value = 3,
FloatValue = 4,
DoubleValue = 5,
StringValue = 6,
BytesValue = 7,
DatetimeValue = 8,
ArrayValue = 9
}
/// <summary>Container for typed arrays. Exactly one field will be set.</summary>
[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<bool> Values { get; set; } = [];
}
[DataContract]
public class Int32Array
{
[DataMember(Order = 1)]
public List<int> Values { get; set; } = [];
}
[DataContract]
public class Int64Array
{
[DataMember(Order = 1)]
public List<long> Values { get; set; } = [];
}
[DataContract]
public class FloatArray
{
[DataMember(Order = 1)]
public List<float> Values { get; set; } = [];
}
[DataContract]
public class DoubleArray
{
[DataMember(Order = 1)]
public List<double> Values { get; set; } = [];
}
[DataContract]
public class StringArray
{
[DataMember(Order = 1)]
public List<string> Values { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// Quality Code (v2)
// ────────────────────────────────────────────────────────────────
/// <summary>
/// OPC UA-style quality code with numeric status code and symbolic name.
/// </summary>
[DataContract]
public class QualityCode
{
[DataMember(Order = 1)]
public uint StatusCode { get; set; }
[DataMember(Order = 2)]
public string SymbolicName { get; set; } = string.Empty;
/// <summary>Returns true if quality category is Good (high bits 0x00).</summary>
public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000;
/// <summary>Returns true if quality category is Uncertain (high bits 0x40).</summary>
public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000;
/// <summary>Returns true if quality category is Bad (high bits 0x80).</summary>
public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000;
}
// ────────────────────────────────────────────────────────────────
// VTQ message (v2)
// ────────────────────────────────────────────────────────────────
[DataContract]
public class VtqMessage
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value encoded as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
[DataMember(Order = 2)]
public TypedValue? Value { get; set; }
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
[DataMember(Order = 3)]
public long TimestampUtcTicks { get; set; }
[DataMember(Order = 3)]
public long TimestampUtcTicks { get; set; }
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
[DataMember(Order = 4)]
public string Quality { get; set; } = string.Empty;
[DataMember(Order = 4)]
public QualityCode? Quality { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Connect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to establish a session with the proxy server.</summary>
[DataContract]
public class ConnectRequest
{
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
[DataMember(Order = 1)]
public string ClientId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string ClientId { get; set; } = string.Empty;
/// <summary>API key for authentication (empty if none required).</summary>
[DataMember(Order = 2)]
public string ApiKey { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from a Connect call.</summary>
[DataContract]
public class ConnectResponse
{
/// <summary>Whether the connection was established successfully.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Session ID (32-char hex GUID). Only valid when <see cref="Success"/> is <c>true</c>.</summary>
[DataMember(Order = 3)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 3)]
public string SessionId { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Disconnect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to terminate a session.</summary>
[DataContract]
public class DisconnectRequest
{
/// <summary>Active session ID to disconnect.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response from a Disconnect call.</summary>
[DataContract]
public class DisconnectResponse
{
/// <summary>Whether the disconnect succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// GetConnectionState
// ────────────────────────────────────────────────────────────────
/// <summary>Request to query connection state for a session.</summary>
[DataContract]
public class GetConnectionStateRequest
{
/// <summary>Session ID to query.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response with connection state information.</summary>
[DataContract]
public class GetConnectionStateResponse
{
/// <summary>Whether the session is currently connected.</summary>
[DataMember(Order = 1)]
public bool IsConnected { get; set; }
[DataMember(Order = 1)]
public bool IsConnected { get; set; }
/// <summary>Client identifier for this session.</summary>
[DataMember(Order = 2)]
public string ClientId { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string ClientId { get; set; } = string.Empty;
/// <summary>UTC ticks when the connection was established.</summary>
[DataMember(Order = 3)]
public long ConnectedSinceUtcTicks { get; set; }
[DataMember(Order = 3)]
public long ConnectedSinceUtcTicks { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Read
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read a single tag.</summary>
[DataContract]
public class ReadRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to read.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Read call.</summary>
[DataContract]
public class ReadResponse
{
/// <summary>Whether the read succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message if the read failed.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>The value-timestamp-quality result.</summary>
[DataMember(Order = 3)]
public VtqMessage? Vtq { get; set; }
[DataMember(Order = 3)]
public VtqMessage? Vtq { get; set; }
}
// ────────────────────────────────────────────────────────────────
// ReadBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read multiple tags in a single round-trip.</summary>
[DataContract]
public class ReadBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to read.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
}
/// <summary>Response from a batch Read call.</summary>
[DataContract]
public class ReadBatchResponse
{
/// <summary>False if any tag read failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>VTQ results in the same order as the request tags.</summary>
[DataMember(Order = 3)]
public List<VtqMessage> Vtqs { get; set; } = [];
[DataMember(Order = 3)]
public List<VtqMessage> Vtqs { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// Write
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write a single tag value.</summary>
[DataContract]
public class WriteRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to write.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string (parsed server-side).</summary>
[DataMember(Order = 3)]
public string Value { get; set; } = string.Empty;
[DataMember(Order = 3)]
public TypedValue? Value { get; set; }
}
/// <summary>Response from a single-tag Write call.</summary>
[DataContract]
public class WriteResponse
{
/// <summary>Whether the write succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteItem / WriteResult
// ────────────────────────────────────────────────────────────────
/// <summary>A single tag-value pair for batch write operations.</summary>
[DataContract]
public class WriteItem
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
[DataMember(Order = 2)]
public TypedValue? Value { get; set; }
}
/// <summary>Per-item result from a batch write operation.</summary>
[DataContract]
public class WriteResult
{
/// <summary>Tag address that was written.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Whether the individual write succeeded.</summary>
[DataMember(Order = 2)]
public bool Success { get; set; }
[DataMember(Order = 2)]
public bool Success { get; set; }
/// <summary>Error message for this item, if any.</summary>
[DataMember(Order = 3)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 3)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
[DataContract]
public class WriteBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
}
/// <summary>Response from a batch Write call.</summary>
[DataContract]
public class WriteBatchResponse
{
/// <summary>Overall success — false if any item failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> Results { get; set; } = [];
[DataMember(Order = 3)]
public List<WriteResult> Results { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// WriteBatchAndWait
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Request to write multiple tag values then poll a flag tag
/// until it matches an expected value or the timeout expires.
/// </summary>
[DataContract]
public class WriteBatchAndWaitRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
/// <summary>Tag to poll after writes complete.</summary>
[DataMember(Order = 3)]
public string FlagTag { get; set; } = string.Empty;
[DataMember(Order = 3)]
public string FlagTag { get; set; } = string.Empty;
/// <summary>Expected value for the flag tag (string comparison).</summary>
[DataMember(Order = 4)]
public string FlagValue { get; set; } = string.Empty;
[DataMember(Order = 4)]
public TypedValue? FlagValue { get; set; }
/// <summary>Timeout in milliseconds (default 5000 if &lt;= 0).</summary>
[DataMember(Order = 5)]
public int TimeoutMs { get; set; }
[DataMember(Order = 5)]
public int TimeoutMs { get; set; }
/// <summary>Poll interval in milliseconds (default 100 if &lt;= 0).</summary>
[DataMember(Order = 6)]
public int PollIntervalMs { get; set; }
[DataMember(Order = 6)]
public int PollIntervalMs { get; set; }
}
/// <summary>Response from a WriteBatchAndWait call.</summary>
[DataContract]
public class WriteBatchAndWaitResponse
{
/// <summary>Overall operation success.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> WriteResults { get; set; } = [];
[DataMember(Order = 3)]
public List<WriteResult> WriteResults { get; set; } = [];
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
[DataMember(Order = 4)]
public bool FlagReached { get; set; }
[DataMember(Order = 4)]
public bool FlagReached { get; set; }
/// <summary>Total elapsed time in milliseconds.</summary>
[DataMember(Order = 5)]
public int ElapsedMs { get; set; }
[DataMember(Order = 5)]
public int ElapsedMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Subscribe
// ────────────────────────────────────────────────────────────────
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
[DataContract]
public class SubscribeRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to monitor.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
/// <summary>Backend sampling interval in milliseconds.</summary>
[DataMember(Order = 3)]
public int SamplingMs { get; set; }
[DataMember(Order = 3)]
public int SamplingMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// CheckApiKey
// ────────────────────────────────────────────────────────────────
/// <summary>Request to validate an API key without creating a session.</summary>
[DataContract]
public class CheckApiKeyRequest
{
/// <summary>API key to validate.</summary>
[DataMember(Order = 1)]
public string ApiKey { get; set; } = string.Empty;
[DataMember(Order = 1)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from an API key validation check.</summary>
[DataContract]
public class CheckApiKeyResponse
{
/// <summary>Whether the API key is valid.</summary>
[DataMember(Order = 1)]
public bool IsValid { get; set; }
[DataMember(Order = 1)]
public bool IsValid { get; set; }
/// <summary>Validation message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}

View File

@@ -1,27 +1,12 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
/// <param name="Value">The value.</param>
/// <param name="Timestamp">The timestamp when the value was read.</param>
/// <param name="Quality">The quality of the value.</param>
/// <summary>Value, Timestamp, and Quality for SCADA data.</summary>
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
{
/// <summary>Creates a new VTQ with the specified value and quality, using the current UTC timestamp.</summary>
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
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);
/// <summary>Creates a new VTQ with the specified value, timestamp, and quality.</summary>
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>Creates a Good-quality VTQ with the current UTC time.</summary>
public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>Creates a Bad-quality VTQ with the current UTC time.</summary>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>Creates an Uncertain-quality VTQ with the current UTC time.</summary>
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
public override string ToString() =>
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
}

View File

@@ -4,11 +4,12 @@
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<Description>gRPC client library for LmxProxy service</Description>
<Description>gRPC client library for LmxProxy SCADA proxy service</Description>
<PlatformTarget>AnyCPU</PlatformTarget>
<Platforms>AnyCPU</Platforms>
</PropertyGroup>

View File

@@ -5,34 +5,11 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
/// </summary>
public enum ConnectionState
{
/// <summary>
/// The client is disconnected.
/// </summary>
Disconnected,
/// <summary>
/// The client is in the process of connecting.
/// </summary>
Connecting,
/// <summary>
/// The client is connected.
/// </summary>
Connected,
/// <summary>
/// The client is in the process of disconnecting.
/// </summary>
Disconnecting,
/// <summary>
/// The client encountered an error.
/// </summary>
Error,
/// <summary>
/// The client is reconnecting after a connection loss.
/// </summary>
Reconnecting
}
}

View File

@@ -7,12 +7,6 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previousState">The previous connection state.</param>
/// <param name="currentState">The current connection state.</param>
/// <param name="message">Optional message providing additional information about the state change.</param>
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
string? message = null)
{
@@ -22,24 +16,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
Message = message;
}
/// <summary>
/// Gets the previous connection state.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets the timestamp when the state change occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets additional information about the state change, such as error messages.
/// </summary>
public string? Message { get; }
}
}

View File

@@ -6,99 +6,62 @@ using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Interface for SCADA system clients.
/// Interface for SCADA system clients (MxAccess wrapper).
/// </summary>
public interface IScadaClient : IAsyncDisposable
{
/// <summary>
/// Gets the connection status.
/// </summary>
/// <summary>Gets whether the client is connected to MxAccess.</summary>
bool IsConnected { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
/// <summary>Gets the current connection state.</summary>
ConnectionState ConnectionState { get; }
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
/// <summary>Occurs when the connection state changes.</summary>
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
/// <summary>
/// Connects to the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <summary>Connects to MxAccess.</summary>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Disconnects from the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <summary>Disconnects from MxAccess.</summary>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The value, timestamp, and quality.</returns>
/// <summary>Reads a single tag value.</summary>
/// <returns>VTQ with typed value.</returns>
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="addresses">The tag addresses.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary of address to VTQ values.</returns>
Task<IReadOnlyDictionary<string, Vtq>>
ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
/// <summary>Reads multiple tag values with semaphore-controlled concurrency.</summary>
/// <returns>Dictionary of address to VTQ.</returns>
Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">Cancellation token.</param>
/// <summary>Writes a single tag value. Value is a native .NET type (not string).</summary>
Task WriteAsync(string address, object value, CancellationToken ct = default);
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="values">Dictionary of address to value.</param>
/// <param name="ct">Cancellation token.</param>
/// <summary>Writes multiple tag values with semaphore-controlled concurrency.</summary>
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
/// <summary>
/// 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).
/// </summary>
/// <param name="values">The regular tag values to write.</param>
/// <param name="flagAddress">The address of the flag tag to write.</param>
/// <param name="flagValue">The value to write to the flag tag.</param>
/// <param name="responseAddress">The address of the response tag to monitor.</param>
/// <param name="responseValue">The expected value of the response tag.</param>
/// <param name="ct">Cancellation token controlling the wait.</param>
/// <returns>
/// <c>true</c> if the response value was observed before cancellation;
/// otherwise <c>false</c>.
/// </returns>
Task<bool> WriteBatchAndWaitAsync(
/// <param name="values">Tag-value pairs to write.</param>
/// <param name="flagTag">Tag to poll after writes.</param>
/// <param name="flagValue">Expected value (type-aware comparison).</param>
/// <param name="timeoutMs">Max wait time in milliseconds.</param>
/// <param name="pollIntervalMs">Poll interval in milliseconds.</param>
/// <param name="ct">Cancellation token.</param>
Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagAddress,
string flagTag,
object flagValue,
string responseAddress,
object responseValue,
int timeoutMs,
int pollIntervalMs,
CancellationToken ct = default);
/// <summary>
/// Subscribes to value changes for specified addresses.
/// </summary>
/// <param name="addresses">The tag addresses to monitor.</param>
/// <param name="callback">Callback for value changes.</param>
/// <param name="ct">Cancellation token.</param>
/// <summary>Subscribes to value changes for specified addresses.</summary>
/// <returns>Subscription handle for unsubscribing.</returns>
Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
Task<IAsyncDisposable> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> callback,
CancellationToken ct = default);
}
}

View File

@@ -2,123 +2,126 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// 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.
/// </summary>
public enum Quality : byte
{
// ─────────────── Bad family (0-31) ───────────────
/// <summary>0x00 Bad [Non-Specific]</summary>
/// <summary>0x00 - Bad [Non-Specific]</summary>
Bad = 0,
/// <summary>0x01 Unknown quality value</summary>
/// <summary>0x01 - Unknown quality value</summary>
Unknown = 1,
/// <summary>0x04 Bad [Configuration Error]</summary>
/// <summary>0x04 - Bad [Configuration Error]</summary>
Bad_ConfigError = 4,
/// <summary>0x08 Bad [Not Connected]</summary>
/// <summary>0x08 - Bad [Not Connected]</summary>
Bad_NotConnected = 8,
/// <summary>0x0C Bad [Device Failure]</summary>
/// <summary>0x0C - Bad [Device Failure]</summary>
Bad_DeviceFailure = 12,
/// <summary>0x10 Bad [Sensor Failure]</summary>
/// <summary>0x10 - Bad [Sensor Failure]</summary>
Bad_SensorFailure = 16,
/// <summary>0x14 Bad [Last Known Value]</summary>
/// <summary>0x14 - Bad [Last Known Value]</summary>
Bad_LastKnownValue = 20,
/// <summary>0x18 Bad [Communication Failure]</summary>
/// <summary>0x18 - Bad [Communication Failure]</summary>
Bad_CommFailure = 24,
/// <summary>0x1C Bad [Out of Service]</summary>
/// <summary>0x1C - Bad [Out of Service]</summary>
Bad_OutOfService = 28,
/// <summary>0x20 - Bad [Waiting for Initial Data]</summary>
Bad_WaitingForInitialData = 32,
// ──────────── Uncertain family (64-95) ───────────
/// <summary>0x40 Uncertain [Non-Specific]</summary>
/// <summary>0x40 - Uncertain [Non-Specific]</summary>
Uncertain = 64,
/// <summary>0x41 Uncertain [Non-Specific] (Low Limited)</summary>
/// <summary>0x41 - Uncertain [Non-Specific] (Low Limited)</summary>
Uncertain_LowLimited = 65,
/// <summary>0x42 Uncertain [Non-Specific] (High Limited)</summary>
/// <summary>0x42 - Uncertain [Non-Specific] (High Limited)</summary>
Uncertain_HighLimited = 66,
/// <summary>0x43 Uncertain [Non-Specific] (Constant)</summary>
/// <summary>0x43 - Uncertain [Non-Specific] (Constant)</summary>
Uncertain_Constant = 67,
/// <summary>0x44 Uncertain [Last Usable]</summary>
/// <summary>0x44 - Uncertain [Last Usable]</summary>
Uncertain_LastUsable = 68,
/// <summary>0x45 Uncertain [Last Usable] (Low Limited)</summary>
/// <summary>0x45 - Uncertain [Last Usable] (Low Limited)</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>0x46 Uncertain [Last Usable] (High Limited)</summary>
/// <summary>0x46 - Uncertain [Last Usable] (High Limited)</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>0x47 Uncertain [Last Usable] (Constant)</summary>
/// <summary>0x47 - Uncertain [Last Usable] (Constant)</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>0x50 Uncertain [Sensor Not Accurate]</summary>
/// <summary>0x50 - Uncertain [Sensor Not Accurate]</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>0x51 Uncertain [Sensor Not Accurate] (Low Limited)</summary>
/// <summary>0x51 - Uncertain [Sensor Not Accurate] (Low Limited)</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>0x52 Uncertain [Sensor Not Accurate] (High Limited)</summary>
/// <summary>0x52 - Uncertain [Sensor Not Accurate] (High Limited)</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>0x53 Uncertain [Sensor Not Accurate] (Constant)</summary>
/// <summary>0x53 - Uncertain [Sensor Not Accurate] (Constant)</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>0x54 Uncertain [EU Exceeded]</summary>
/// <summary>0x54 - Uncertain [EU Exceeded]</summary>
Uncertain_EuExceeded = 84,
/// <summary>0x55 Uncertain [EU Exceeded] (Low Limited)</summary>
/// <summary>0x55 - Uncertain [EU Exceeded] (Low Limited)</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>0x56 Uncertain [EU Exceeded] (High Limited)</summary>
/// <summary>0x56 - Uncertain [EU Exceeded] (High Limited)</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>0x57 Uncertain [EU Exceeded] (Constant)</summary>
/// <summary>0x57 - Uncertain [EU Exceeded] (Constant)</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>0x58 Uncertain [Sub-Normal]</summary>
/// <summary>0x58 - Uncertain [Sub-Normal]</summary>
Uncertain_SubNormal = 88,
/// <summary>0x59 Uncertain [Sub-Normal] (Low Limited)</summary>
/// <summary>0x59 - Uncertain [Sub-Normal] (Low Limited)</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>0x5A Uncertain [Sub-Normal] (High Limited)</summary>
/// <summary>0x5A - Uncertain [Sub-Normal] (High Limited)</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>0x5B Uncertain [Sub-Normal] (Constant)</summary>
/// <summary>0x5B - Uncertain [Sub-Normal] (Constant)</summary>
Uncertain_SubNormal_C = 91,
// ─────────────── Good family (192-219) ────────────
/// <summary>0xC0 Good [Non-Specific]</summary>
/// <summary>0xC0 - Good [Non-Specific]</summary>
Good = 192,
/// <summary>0xC1 Good [Non-Specific] (Low Limited)</summary>
/// <summary>0xC1 - Good [Non-Specific] (Low Limited)</summary>
Good_LowLimited = 193,
/// <summary>0xC2 Good [Non-Specific] (High Limited)</summary>
/// <summary>0xC2 - Good [Non-Specific] (High Limited)</summary>
Good_HighLimited = 194,
/// <summary>0xC3 Good [Non-Specific] (Constant)</summary>
/// <summary>0xC3 - Good [Non-Specific] (Constant)</summary>
Good_Constant = 195,
/// <summary>0xD8 Good [Local Override]</summary>
/// <summary>0xD8 - Good [Local Override]</summary>
Good_LocalOverride = 216,
/// <summary>0xD9 Good [Local Override] (Low Limited)</summary>
/// <summary>0xD9 - Good [Local Override] (Low Limited)</summary>
Good_LocalOverride_LL = 217,
/// <summary>0xDA Good [Local Override] (High Limited)</summary>
/// <summary>0xDA - Good [Local Override] (High Limited)</summary>
Good_LocalOverride_HL = 218,
/// <summary>0xDB Good [Local Override] (Constant)</summary>
/// <summary>0xDB - Good [Local Override] (Constant)</summary>
Good_LocalOverride_C = 219
}
}

View File

@@ -0,0 +1,167 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Maps between the domain <see cref="Quality"/> enum and proto QualityCode messages.
/// status_code (uint32) is canonical. symbolic_name is derived from a lookup table.
/// </summary>
public static class QualityCodeMapper
{
/// <summary>OPC UA status code → symbolic name lookup.</summary>
private static readonly Dictionary<uint, string> StatusCodeToName = new Dictionary<uint, string>
{
// 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" },
};
/// <summary>Domain Quality enum → OPC UA status code.</summary>
private static readonly Dictionary<Quality, uint> QualityToStatusCode = new Dictionary<Quality, uint>
{
// 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 },
};
/// <summary>
/// Converts a domain Quality enum to a proto QualityCode message.
/// </summary>
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
};
}
/// <summary>OPC UA status code → primary domain Quality (reverse lookup).</summary>
private static readonly Dictionary<uint, Quality> StatusCodeToQuality = new Dictionary<uint, Quality>
{
// 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 },
};
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Gets the symbolic name for a status code.
/// </summary>
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";
}
/// <summary>
/// Creates a QualityCode for a specific well-known status.
/// </summary>
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" };
}
}

View File

@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Extension methods for the <see cref="Quality"/> enum.
/// </summary>
public static class QualityExtensions
{
/// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
public static bool IsGood(this Quality q) => (byte)q >= 192;
/// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128;
/// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
public static bool IsBad(this Quality q) => (byte)q < 64;
}
}

View File

@@ -0,0 +1,211 @@
using System;
using Google.Protobuf;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Converts between COM variant objects (boxed .NET types from MxAccess)
/// and proto-generated <see cref="Scada.TypedValue"/> messages.
/// </summary>
public static class TypedValueConverter
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter));
/// <summary>
/// Converts a COM variant object to a proto TypedValue.
/// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL.
/// </summary>
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() };
}
}
/// <summary>
/// Converts a proto TypedValue back to a boxed .NET object.
/// Returns null for unset oneof (null TypedValue or ValueCase.None).
/// </summary>
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<bool>();
case Scada.ArrayValue.ValuesOneofCase.Int32Values:
return arrayValue.Int32Values?.Values?.Count > 0
? ToArray(arrayValue.Int32Values.Values)
: Array.Empty<int>();
case Scada.ArrayValue.ValuesOneofCase.Int64Values:
return arrayValue.Int64Values?.Values?.Count > 0
? ToArray(arrayValue.Int64Values.Values)
: Array.Empty<long>();
case Scada.ArrayValue.ValuesOneofCase.FloatValues:
return arrayValue.FloatValues?.Values?.Count > 0
? ToArray(arrayValue.FloatValues.Values)
: Array.Empty<float>();
case Scada.ArrayValue.ValuesOneofCase.DoubleValues:
return arrayValue.DoubleValues?.Values?.Count > 0
? ToArray(arrayValue.DoubleValues.Values)
: Array.Empty<double>();
case Scada.ArrayValue.ValuesOneofCase.StringValues:
return arrayValue.StringValues?.Values?.Count > 0
? ToArray(arrayValue.StringValues.Values)
: Array.Empty<string>();
default:
return null;
}
}
private static T[] ToArray<T>(Google.Protobuf.Collections.RepeatedField<T> repeatedField)
{
var result = new T[repeatedField.Count];
for (int i = 0; i < repeatedField.Count; i++)
result[i] = repeatedField[i];
return result;
}
}
}

View File

@@ -7,27 +7,15 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the value.
/// </summary>
/// <summary>Gets the value. Null represents an unset/missing value.</summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp when the value was read.
/// </summary>
/// <summary>Gets the UTC timestamp when the value was read.</summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality of the value.
/// </summary>
/// <summary>Gets the quality of the value.</summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
@@ -35,63 +23,17 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
Quality = quality;
}
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value and quality, using the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value, timestamp, and quality.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with good quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with good quality.</returns>
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);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with bad quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value. Optional.</param>
/// <returns>A new <see cref="Vtq" /> instance with bad quality.</returns>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with uncertain quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with uncertain quality.</returns>
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Determines whether the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="other">The <see cref="Vtq" /> to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
/// <summary>
/// Determines whether the specified object is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="obj">The object to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified object is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public override bool Equals(object obj) => obj is Vtq other && Equals(other);
/// <summary>
/// Returns the hash code for this instance.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode()
{
unchecked
@@ -103,27 +45,10 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
}
}
/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString() =>
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are equal; otherwise, false.</returns>
public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are not equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are not equal; otherwise, false.</returns>
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
}
}

View File

@@ -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;
}

View File

@@ -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<LmxProxyService>(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.
}
}
}

View File

@@ -8,35 +8,37 @@
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
<!-- Force x86 architecture for all configurations (required by ArchestrA.MXAccess) -->
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core" Version="2.46.6"/>
<PackageReference Include="Grpc.Tools" Version="2.51.0">
<PackageReference Include="Grpc.Core" Version="2.46.6" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.21.12"/>
<PackageReference Include="Topshelf" Version="4.3.0"/>
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
<PackageReference Include="Serilog" Version="2.10.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0"/>
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0"/>
<PackageReference Include="System.Threading.Channels" Version="4.7.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32"/>
<PackageReference Include="Polly" Version="7.2.4"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1"/>
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Topshelf" Version="4.3.0" />
<PackageReference Include="Topshelf.Serilog" Version="4.3.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.2.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="System.Threading.Channels" Version="4.7.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32" />
<PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32" />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
@@ -47,7 +49,7 @@
</ItemGroup>
<ItemGroup>
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both"/>
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both" />
</ItemGroup>
<ItemGroup>
@@ -57,9 +59,6 @@
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="App.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -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"
]
}
}

View File

@@ -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;
/// <summary>
/// Verifies wire compatibility between Host proto-generated types and Client code-first types.
/// Serializes with one stack, deserializes with the other.
/// </summary>
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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.VtqMessage>(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<CodeFirst.ConnectResponse>(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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
</ItemGroup>
<!-- Proto file for cross-stack serialization tests (Host proto → Client code-first) -->
<ItemGroup>
<Protobuf Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\Grpc\Protos\scada.proto" GrpcServices="None" Link="Protos\scada.proto" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Host.Tests</RootNamespace>
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" />
</ItemGroup>
</Project>