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:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the connection state of an LmxProxy client.
|
||||
/// </summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
/// <summary>Not connected to the server.</summary>
|
||||
Disconnected,
|
||||
|
||||
/// <summary>Connection attempt in progress.</summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>Connected and ready for operations.</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>Graceful disconnect in progress.</summary>
|
||||
Disconnecting,
|
||||
|
||||
/// <summary>Connection failed with an error.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Attempting to re-establish a lost connection.</summary>
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for connection state change notifications.
|
||||
/// </summary>
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>The previous connection state.</summary>
|
||||
public ConnectionState OldState { get; }
|
||||
|
||||
/// <summary>The new connection state.</summary>
|
||||
public ConnectionState NewState { get; }
|
||||
|
||||
/// <summary>Optional message describing the state change (e.g., error details).</summary>
|
||||
public string? Message { get; }
|
||||
|
||||
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null)
|
||||
{
|
||||
OldState = oldState;
|
||||
NewState = newState;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// OPC-style quality codes for SCADA data values.
|
||||
/// Based on OPC DA quality encoding as a single byte:
|
||||
/// bits 7–6 = major (00=Bad, 01=Uncertain, 11=Good),
|
||||
/// bits 5–2 = substatus, bits 1–0 = limit (00=None, 01=Low, 10=High, 11=Constant).
|
||||
/// </summary>
|
||||
public enum Quality : byte
|
||||
{
|
||||
/// <summary>Bad – non-specific.</summary>
|
||||
Bad = 0,
|
||||
|
||||
/// <summary>Bad – configuration error in the server.</summary>
|
||||
Bad_ConfigError = 4,
|
||||
|
||||
/// <summary>Bad – input source is not connected.</summary>
|
||||
Bad_NotConnected = 8,
|
||||
|
||||
/// <summary>Bad – device failure detected.</summary>
|
||||
Bad_DeviceFailure = 12,
|
||||
|
||||
/// <summary>Bad – sensor failure detected.</summary>
|
||||
Bad_SensorFailure = 16,
|
||||
|
||||
/// <summary>Bad – last known value (communication lost, value stale).</summary>
|
||||
Bad_LastKnownValue = 20,
|
||||
|
||||
/// <summary>Bad – communication failure.</summary>
|
||||
Bad_CommFailure = 24,
|
||||
|
||||
/// <summary>Bad – item is out of service.</summary>
|
||||
Bad_OutOfService = 28,
|
||||
|
||||
/// <summary>Uncertain – non-specific.</summary>
|
||||
Uncertain = 64,
|
||||
|
||||
/// <summary>Uncertain – non-specific, low limited.</summary>
|
||||
Uncertain_LowLimited = 65,
|
||||
|
||||
/// <summary>Uncertain – non-specific, high limited.</summary>
|
||||
Uncertain_HighLimited = 66,
|
||||
|
||||
/// <summary>Uncertain – non-specific, constant.</summary>
|
||||
Uncertain_Constant = 67,
|
||||
|
||||
/// <summary>Uncertain – last usable value.</summary>
|
||||
Uncertain_LastUsable = 68,
|
||||
|
||||
/// <summary>Uncertain – last usable value, low limited.</summary>
|
||||
Uncertain_LastUsable_LL = 69,
|
||||
|
||||
/// <summary>Uncertain – last usable value, high limited.</summary>
|
||||
Uncertain_LastUsable_HL = 70,
|
||||
|
||||
/// <summary>Uncertain – last usable value, constant.</summary>
|
||||
Uncertain_LastUsable_Cnst = 71,
|
||||
|
||||
/// <summary>Uncertain – sensor not accurate.</summary>
|
||||
Uncertain_SensorNotAcc = 80,
|
||||
|
||||
/// <summary>Uncertain – sensor not accurate, low limited.</summary>
|
||||
Uncertain_SensorNotAcc_LL = 81,
|
||||
|
||||
/// <summary>Uncertain – sensor not accurate, high limited.</summary>
|
||||
Uncertain_SensorNotAcc_HL = 82,
|
||||
|
||||
/// <summary>Uncertain – sensor not accurate, constant.</summary>
|
||||
Uncertain_SensorNotAcc_C = 83,
|
||||
|
||||
/// <summary>Uncertain – engineering units exceeded.</summary>
|
||||
Uncertain_EuExceeded = 84,
|
||||
|
||||
/// <summary>Uncertain – engineering units exceeded, low limited.</summary>
|
||||
Uncertain_EuExceeded_LL = 85,
|
||||
|
||||
/// <summary>Uncertain – engineering units exceeded, high limited.</summary>
|
||||
Uncertain_EuExceeded_HL = 86,
|
||||
|
||||
/// <summary>Uncertain – engineering units exceeded, constant.</summary>
|
||||
Uncertain_EuExceeded_C = 87,
|
||||
|
||||
/// <summary>Uncertain – sub-normal operating conditions.</summary>
|
||||
Uncertain_SubNormal = 88,
|
||||
|
||||
/// <summary>Uncertain – sub-normal, low limited.</summary>
|
||||
Uncertain_SubNormal_LL = 89,
|
||||
|
||||
/// <summary>Uncertain – sub-normal, high limited.</summary>
|
||||
Uncertain_SubNormal_HL = 90,
|
||||
|
||||
/// <summary>Uncertain – sub-normal, constant.</summary>
|
||||
Uncertain_SubNormal_C = 91,
|
||||
|
||||
/// <summary>Good – non-specific.</summary>
|
||||
Good = 192,
|
||||
|
||||
/// <summary>Good – low limited.</summary>
|
||||
Good_LowLimited = 193,
|
||||
|
||||
/// <summary>Good – high limited.</summary>
|
||||
Good_HighLimited = 194,
|
||||
|
||||
/// <summary>Good – constant.</summary>
|
||||
Good_Constant = 195,
|
||||
|
||||
/// <summary>Good – local override active.</summary>
|
||||
Good_LocalOverride = 216,
|
||||
|
||||
/// <summary>Good – local override active, low limited.</summary>
|
||||
Good_LocalOverride_LL = 217,
|
||||
|
||||
/// <summary>Good – local override active, high limited.</summary>
|
||||
Good_LocalOverride_HL = 218,
|
||||
|
||||
/// <summary>Good – local override active, constant.</summary>
|
||||
Good_LocalOverride_C = 219
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
public static class QualityExtensions
|
||||
{
|
||||
public static bool IsGood(this Quality q) => (byte)q >= 128;
|
||||
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
|
||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using System.ServiceModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Service contract
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Code-first gRPC service contract for SCADA operations.
|
||||
/// </summary>
|
||||
[ServiceContract(Name = "scada.ScadaService")]
|
||||
public interface IScadaService
|
||||
{
|
||||
/// <summary>Establishes a connection with the SCADA service.</summary>
|
||||
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
|
||||
|
||||
/// <summary>Terminates a SCADA service connection.</summary>
|
||||
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
|
||||
|
||||
/// <summary>Retrieves the current state of a SCADA connection.</summary>
|
||||
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
|
||||
|
||||
/// <summary>Reads a single tag value from the SCADA system.</summary>
|
||||
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
|
||||
|
||||
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
|
||||
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
|
||||
|
||||
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
|
||||
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
|
||||
|
||||
/// <summary>Writes multiple values to tags in the SCADA system in a batch operation.</summary>
|
||||
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
|
||||
|
||||
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
|
||||
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
|
||||
|
||||
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
|
||||
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates an API key for authentication.</summary>
|
||||
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// VTQ message
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Value-Timestamp-Quality message transmitted over gRPC.
|
||||
/// All values are string-encoded; timestamps are UTC ticks.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class VtqMessage
|
||||
{
|
||||
/// <summary>Tag address.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Value encoded as a string.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public long TimestampUtcTicks { get; set; }
|
||||
|
||||
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string Quality { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Connect
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to establish a session with the proxy server.</summary>
|
||||
[DataContract]
|
||||
public class ConnectRequest
|
||||
{
|
||||
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>API key for authentication (empty if none required).</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response from a Connect call.</summary>
|
||||
[DataContract]
|
||||
public class ConnectResponse
|
||||
{
|
||||
/// <summary>Whether the connection was established successfully.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Status or error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Session ID (32-char hex GUID). Only valid when <see cref="Success"/> is <c>true</c>.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Disconnect
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to terminate a session.</summary>
|
||||
[DataContract]
|
||||
public class DisconnectRequest
|
||||
{
|
||||
/// <summary>Active session ID to disconnect.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response from a Disconnect call.</summary>
|
||||
[DataContract]
|
||||
public class DisconnectResponse
|
||||
{
|
||||
/// <summary>Whether the disconnect succeeded.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Status or error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// GetConnectionState
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to query connection state for a session.</summary>
|
||||
[DataContract]
|
||||
public class GetConnectionStateRequest
|
||||
{
|
||||
/// <summary>Session ID to query.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response with connection state information.</summary>
|
||||
[DataContract]
|
||||
public class GetConnectionStateResponse
|
||||
{
|
||||
/// <summary>Whether the session is currently connected.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
/// <summary>Client identifier for this session.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC ticks when the connection was established.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public long ConnectedSinceUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Read
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to read a single tag.</summary>
|
||||
[DataContract]
|
||||
public class ReadRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag address to read.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response from a single-tag Read call.</summary>
|
||||
[DataContract]
|
||||
public class ReadResponse
|
||||
{
|
||||
/// <summary>Whether the read succeeded.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Error message if the read failed.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The value-timestamp-quality result.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public VtqMessage? Vtq { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// ReadBatch
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to read multiple tags in a single round-trip.</summary>
|
||||
[DataContract]
|
||||
public class ReadBatchRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag addresses to read.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Response from a batch Read call.</summary>
|
||||
[DataContract]
|
||||
public class ReadBatchResponse
|
||||
{
|
||||
/// <summary>False if any tag read failed.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>VTQ results in the same order as the request tags.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public List<VtqMessage> Vtqs { get; set; } = [];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Write
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to write a single tag value.</summary>
|
||||
[DataContract]
|
||||
public class WriteRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag address to write.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Value as a string (parsed server-side).</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response from a single-tag Write call.</summary>
|
||||
[DataContract]
|
||||
public class WriteResponse
|
||||
{
|
||||
/// <summary>Whether the write succeeded.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Status or error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WriteItem / WriteResult
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>A single tag-value pair for batch write operations.</summary>
|
||||
[DataContract]
|
||||
public class WriteItem
|
||||
{
|
||||
/// <summary>Tag address.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Value as a string.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Per-item result from a batch write operation.</summary>
|
||||
[DataContract]
|
||||
public class WriteResult
|
||||
{
|
||||
/// <summary>Tag address that was written.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the individual write succeeded.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Error message for this item, if any.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WriteBatch
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
|
||||
[DataContract]
|
||||
public class WriteBatchRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag-value pairs to write.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<WriteItem> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Response from a batch Write call.</summary>
|
||||
[DataContract]
|
||||
public class WriteBatchResponse
|
||||
{
|
||||
/// <summary>Overall success — false if any item failed.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Status or error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-item write results.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public List<WriteResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WriteBatchAndWait
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Request to write multiple tag values then poll a flag tag
|
||||
/// until it matches an expected value or the timeout expires.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class WriteBatchAndWaitRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag-value pairs to write.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<WriteItem> Items { get; set; } = [];
|
||||
|
||||
/// <summary>Tag to poll after writes complete.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string FlagTag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Expected value for the flag tag (string comparison).</summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string FlagValue { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timeout in milliseconds (default 5000 if <= 0).</summary>
|
||||
[DataMember(Order = 5)]
|
||||
public int TimeoutMs { get; set; }
|
||||
|
||||
/// <summary>Poll interval in milliseconds (default 100 if <= 0).</summary>
|
||||
[DataMember(Order = 6)]
|
||||
public int PollIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response from a WriteBatchAndWait call.</summary>
|
||||
[DataContract]
|
||||
public class WriteBatchAndWaitResponse
|
||||
{
|
||||
/// <summary>Overall operation success.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Status or error message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-item write results.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public List<WriteResult> WriteResults { get; set; } = [];
|
||||
|
||||
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
|
||||
[DataMember(Order = 4)]
|
||||
public bool FlagReached { get; set; }
|
||||
|
||||
/// <summary>Total elapsed time in milliseconds.</summary>
|
||||
[DataMember(Order = 5)]
|
||||
public int ElapsedMs { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Subscribe
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
|
||||
[DataContract]
|
||||
public class SubscribeRequest
|
||||
{
|
||||
/// <summary>Valid session ID.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tag addresses to monitor.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>Backend sampling interval in milliseconds.</summary>
|
||||
[DataMember(Order = 3)]
|
||||
public int SamplingMs { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// CheckApiKey
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Request to validate an API key without creating a session.</summary>
|
||||
[DataContract]
|
||||
public class CheckApiKeyRequest
|
||||
{
|
||||
/// <summary>API key to validate.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response from an API key validation check.</summary>
|
||||
[DataContract]
|
||||
public class CheckApiKeyResponse
|
||||
{
|
||||
/// <summary>Whether the API key is valid.</summary>
|
||||
[DataMember(Order = 1)]
|
||||
public bool IsValid { get; set; }
|
||||
|
||||
/// <summary>Validation message.</summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Value, Timestamp, and Quality structure for SCADA data.
|
||||
/// </summary>
|
||||
/// <param name="Value">The value.</param>
|
||||
/// <param name="Timestamp">The timestamp when the value was read.</param>
|
||||
/// <param name="Quality">The quality of the value.</param>
|
||||
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
|
||||
{
|
||||
/// <summary>Creates a new VTQ with the specified value and quality, using the current UTC timestamp.</summary>
|
||||
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
|
||||
|
||||
/// <summary>Creates a new VTQ with the specified value, timestamp, and quality.</summary>
|
||||
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
|
||||
|
||||
/// <summary>Creates a Good-quality VTQ with the current UTC time.</summary>
|
||||
public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good);
|
||||
|
||||
/// <summary>Creates a Bad-quality VTQ with the current UTC time.</summary>
|
||||
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
|
||||
|
||||
/// <summary>Creates an Uncertain-quality VTQ with the current UTC time.</summary>
|
||||
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
124
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs
Normal file
124
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs
Normal 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
|
||||
}
|
||||
}
|
||||
129
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs
Normal file
129
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
87
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal file
87
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 7–6 = major (00=Bad, 01=Uncertain, 11=Good),
|
||||
/// bits 5–2 = substatus, bits 1–0 = limit (00=None, 01=Low, 10=High, 11=Constant).
|
||||
/// Byte value matches OPC DA quality low byte for direct round-trip.
|
||||
/// </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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <= 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 <= 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;
|
||||
}
|
||||
|
||||
@@ -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}}}";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
167
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs
Normal file
167
lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user