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.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
|
||||||
<Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
|
<Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
|
||||||
</Folder>
|
</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>
|
</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;
|
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Represents the state of a connection to the LmxProxy service.</summary>
|
||||||
/// Represents the connection state of an LmxProxy client.
|
|
||||||
/// </summary>
|
|
||||||
public enum ConnectionState
|
public enum ConnectionState
|
||||||
{
|
{
|
||||||
/// <summary>Not connected to the server.</summary>
|
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
|
||||||
/// <summary>Connection attempt in progress.</summary>
|
|
||||||
Connecting,
|
Connecting,
|
||||||
|
|
||||||
/// <summary>Connected and ready for operations.</summary>
|
|
||||||
Connected,
|
Connected,
|
||||||
|
|
||||||
/// <summary>Graceful disconnect in progress.</summary>
|
|
||||||
Disconnecting,
|
Disconnecting,
|
||||||
|
|
||||||
/// <summary>Connection failed with an error.</summary>
|
|
||||||
Error,
|
Error,
|
||||||
|
|
||||||
/// <summary>Attempting to re-establish a lost connection.</summary>
|
|
||||||
Reconnecting
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,117 +2,50 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OPC-style quality codes for SCADA data values.
|
/// OPC-style quality codes for SCADA data values.
|
||||||
/// Based on OPC DA quality encoding as a single byte:
|
/// Byte value matches OPC DA quality low byte for direct round-trip.
|
||||||
/// 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>
|
/// </summary>
|
||||||
public enum Quality : byte
|
public enum Quality : byte
|
||||||
{
|
{
|
||||||
/// <summary>Bad – non-specific.</summary>
|
// ─────────────── Bad family (0-31) ───────────────
|
||||||
Bad = 0,
|
Bad = 0,
|
||||||
|
|
||||||
/// <summary>Bad – configuration error in the server.</summary>
|
|
||||||
Bad_ConfigError = 4,
|
Bad_ConfigError = 4,
|
||||||
|
|
||||||
/// <summary>Bad – input source is not connected.</summary>
|
|
||||||
Bad_NotConnected = 8,
|
Bad_NotConnected = 8,
|
||||||
|
|
||||||
/// <summary>Bad – device failure detected.</summary>
|
|
||||||
Bad_DeviceFailure = 12,
|
Bad_DeviceFailure = 12,
|
||||||
|
|
||||||
/// <summary>Bad – sensor failure detected.</summary>
|
|
||||||
Bad_SensorFailure = 16,
|
Bad_SensorFailure = 16,
|
||||||
|
|
||||||
/// <summary>Bad – last known value (communication lost, value stale).</summary>
|
|
||||||
Bad_LastKnownValue = 20,
|
Bad_LastKnownValue = 20,
|
||||||
|
|
||||||
/// <summary>Bad – communication failure.</summary>
|
|
||||||
Bad_CommFailure = 24,
|
Bad_CommFailure = 24,
|
||||||
|
|
||||||
/// <summary>Bad – item is out of service.</summary>
|
|
||||||
Bad_OutOfService = 28,
|
Bad_OutOfService = 28,
|
||||||
|
Bad_WaitingForInitialData = 32,
|
||||||
|
|
||||||
/// <summary>Uncertain – non-specific.</summary>
|
// ──────────── Uncertain family (64-95) ───────────
|
||||||
Uncertain = 64,
|
Uncertain = 64,
|
||||||
|
|
||||||
/// <summary>Uncertain – non-specific, low limited.</summary>
|
|
||||||
Uncertain_LowLimited = 65,
|
Uncertain_LowLimited = 65,
|
||||||
|
|
||||||
/// <summary>Uncertain – non-specific, high limited.</summary>
|
|
||||||
Uncertain_HighLimited = 66,
|
Uncertain_HighLimited = 66,
|
||||||
|
|
||||||
/// <summary>Uncertain – non-specific, constant.</summary>
|
|
||||||
Uncertain_Constant = 67,
|
Uncertain_Constant = 67,
|
||||||
|
|
||||||
/// <summary>Uncertain – last usable value.</summary>
|
|
||||||
Uncertain_LastUsable = 68,
|
Uncertain_LastUsable = 68,
|
||||||
|
|
||||||
/// <summary>Uncertain – last usable value, low limited.</summary>
|
|
||||||
Uncertain_LastUsable_LL = 69,
|
Uncertain_LastUsable_LL = 69,
|
||||||
|
|
||||||
/// <summary>Uncertain – last usable value, high limited.</summary>
|
|
||||||
Uncertain_LastUsable_HL = 70,
|
Uncertain_LastUsable_HL = 70,
|
||||||
|
|
||||||
/// <summary>Uncertain – last usable value, constant.</summary>
|
|
||||||
Uncertain_LastUsable_Cnst = 71,
|
Uncertain_LastUsable_Cnst = 71,
|
||||||
|
|
||||||
/// <summary>Uncertain – sensor not accurate.</summary>
|
|
||||||
Uncertain_SensorNotAcc = 80,
|
Uncertain_SensorNotAcc = 80,
|
||||||
|
|
||||||
/// <summary>Uncertain – sensor not accurate, low limited.</summary>
|
|
||||||
Uncertain_SensorNotAcc_LL = 81,
|
Uncertain_SensorNotAcc_LL = 81,
|
||||||
|
|
||||||
/// <summary>Uncertain – sensor not accurate, high limited.</summary>
|
|
||||||
Uncertain_SensorNotAcc_HL = 82,
|
Uncertain_SensorNotAcc_HL = 82,
|
||||||
|
|
||||||
/// <summary>Uncertain – sensor not accurate, constant.</summary>
|
|
||||||
Uncertain_SensorNotAcc_C = 83,
|
Uncertain_SensorNotAcc_C = 83,
|
||||||
|
|
||||||
/// <summary>Uncertain – engineering units exceeded.</summary>
|
|
||||||
Uncertain_EuExceeded = 84,
|
Uncertain_EuExceeded = 84,
|
||||||
|
|
||||||
/// <summary>Uncertain – engineering units exceeded, low limited.</summary>
|
|
||||||
Uncertain_EuExceeded_LL = 85,
|
Uncertain_EuExceeded_LL = 85,
|
||||||
|
|
||||||
/// <summary>Uncertain – engineering units exceeded, high limited.</summary>
|
|
||||||
Uncertain_EuExceeded_HL = 86,
|
Uncertain_EuExceeded_HL = 86,
|
||||||
|
|
||||||
/// <summary>Uncertain – engineering units exceeded, constant.</summary>
|
|
||||||
Uncertain_EuExceeded_C = 87,
|
Uncertain_EuExceeded_C = 87,
|
||||||
|
|
||||||
/// <summary>Uncertain – sub-normal operating conditions.</summary>
|
|
||||||
Uncertain_SubNormal = 88,
|
Uncertain_SubNormal = 88,
|
||||||
|
|
||||||
/// <summary>Uncertain – sub-normal, low limited.</summary>
|
|
||||||
Uncertain_SubNormal_LL = 89,
|
Uncertain_SubNormal_LL = 89,
|
||||||
|
|
||||||
/// <summary>Uncertain – sub-normal, high limited.</summary>
|
|
||||||
Uncertain_SubNormal_HL = 90,
|
Uncertain_SubNormal_HL = 90,
|
||||||
|
|
||||||
/// <summary>Uncertain – sub-normal, constant.</summary>
|
|
||||||
Uncertain_SubNormal_C = 91,
|
Uncertain_SubNormal_C = 91,
|
||||||
|
|
||||||
/// <summary>Good – non-specific.</summary>
|
// ─────────────── Good family (192-219) ────────────
|
||||||
Good = 192,
|
Good = 192,
|
||||||
|
|
||||||
/// <summary>Good – low limited.</summary>
|
|
||||||
Good_LowLimited = 193,
|
Good_LowLimited = 193,
|
||||||
|
|
||||||
/// <summary>Good – high limited.</summary>
|
|
||||||
Good_HighLimited = 194,
|
Good_HighLimited = 194,
|
||||||
|
|
||||||
/// <summary>Good – constant.</summary>
|
|
||||||
Good_Constant = 195,
|
Good_Constant = 195,
|
||||||
|
|
||||||
/// <summary>Good – local override active.</summary>
|
|
||||||
Good_LocalOverride = 216,
|
Good_LocalOverride = 216,
|
||||||
|
|
||||||
/// <summary>Good – local override active, low limited.</summary>
|
|
||||||
Good_LocalOverride_LL = 217,
|
Good_LocalOverride_LL = 217,
|
||||||
|
|
||||||
/// <summary>Good – local override active, high limited.</summary>
|
|
||||||
Good_LocalOverride_HL = 218,
|
Good_LocalOverride_HL = 218,
|
||||||
|
|
||||||
/// <summary>Good – local override active, constant.</summary>
|
|
||||||
Good_LocalOverride_C = 219
|
Good_LocalOverride_C = 219
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>Extension methods for <see cref="Quality"/>.</summary>
|
||||||
public static class QualityExtensions
|
public static class QualityExtensions
|
||||||
{
|
{
|
||||||
public static bool IsGood(this Quality q) => (byte)q >= 128;
|
/// <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;
|
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;
|
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,101 +10,230 @@ namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
|||||||
// Service contract
|
// Service contract
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Code-first gRPC service contract for SCADA operations.
|
|
||||||
/// </summary>
|
|
||||||
[ServiceContract(Name = "scada.ScadaService")]
|
[ServiceContract(Name = "scada.ScadaService")]
|
||||||
public interface IScadaService
|
public interface IScadaService
|
||||||
{
|
{
|
||||||
/// <summary>Establishes a connection with the SCADA service.</summary>
|
|
||||||
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
|
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
|
||||||
|
|
||||||
/// <summary>Terminates a SCADA service connection.</summary>
|
|
||||||
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
|
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
|
||||||
|
|
||||||
/// <summary>Retrieves the current state of a SCADA connection.</summary>
|
|
||||||
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
|
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
|
||||||
|
|
||||||
/// <summary>Reads a single tag value from the SCADA system.</summary>
|
|
||||||
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
|
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
|
||||||
|
|
||||||
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
|
|
||||||
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
|
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
|
||||||
|
|
||||||
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
|
|
||||||
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
|
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);
|
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
|
||||||
|
|
||||||
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
|
|
||||||
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
|
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
|
||||||
|
|
||||||
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
|
|
||||||
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
|
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Validates an API key for authentication.</summary>
|
|
||||||
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
|
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// VTQ message
|
// Typed Value System (v2)
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Value-Timestamp-Quality message transmitted over gRPC.
|
/// Carries a value in its native type via a protobuf oneof.
|
||||||
/// All values are string-encoded; timestamps are UTC ticks.
|
/// 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>
|
/// </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]
|
[DataContract]
|
||||||
public class VtqMessage
|
public class VtqMessage
|
||||||
{
|
{
|
||||||
/// <summary>Tag address.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Value encoded as a string.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Value { get; set; } = string.Empty;
|
public TypedValue? Value { get; set; }
|
||||||
|
|
||||||
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public long TimestampUtcTicks { get; set; }
|
public long TimestampUtcTicks { get; set; }
|
||||||
|
|
||||||
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
|
|
||||||
[DataMember(Order = 4)]
|
[DataMember(Order = 4)]
|
||||||
public string Quality { get; set; } = string.Empty;
|
public QualityCode? Quality { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Connect
|
// Connect
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to establish a session with the proxy server.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ConnectRequest
|
public class ConnectRequest
|
||||||
{
|
{
|
||||||
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>API key for authentication (empty if none required).</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string ApiKey { get; set; } = string.Empty;
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a Connect call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ConnectResponse
|
public class ConnectResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the connection was established successfully.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Status or error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
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)]
|
[DataMember(Order = 3)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -113,24 +242,19 @@ public class ConnectResponse
|
|||||||
// Disconnect
|
// Disconnect
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to terminate a session.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class DisconnectRequest
|
public class DisconnectRequest
|
||||||
{
|
{
|
||||||
/// <summary>Active session ID to disconnect.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a Disconnect call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class DisconnectResponse
|
public class DisconnectResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the disconnect succeeded.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Status or error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -139,28 +263,22 @@ public class DisconnectResponse
|
|||||||
// GetConnectionState
|
// GetConnectionState
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to query connection state for a session.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class GetConnectionStateRequest
|
public class GetConnectionStateRequest
|
||||||
{
|
{
|
||||||
/// <summary>Session ID to query.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response with connection state information.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class GetConnectionStateResponse
|
public class GetConnectionStateResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the session is currently connected.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool IsConnected { get; set; }
|
public bool IsConnected { get; set; }
|
||||||
|
|
||||||
/// <summary>Client identifier for this session.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>UTC ticks when the connection was established.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public long ConnectedSinceUtcTicks { get; set; }
|
public long ConnectedSinceUtcTicks { get; set; }
|
||||||
}
|
}
|
||||||
@@ -169,66 +287,48 @@ public class GetConnectionStateResponse
|
|||||||
// Read
|
// Read
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to read a single tag.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ReadRequest
|
public class ReadRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag address to read.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a single-tag Read call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ReadResponse
|
public class ReadResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the read succeeded.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Error message if the read failed.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>The value-timestamp-quality result.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public VtqMessage? Vtq { get; set; }
|
public VtqMessage? Vtq { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
// ReadBatch
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Request to read multiple tags in a single round-trip.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ReadBatchRequest
|
public class ReadBatchRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag addresses to read.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a batch Read call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class ReadBatchResponse
|
public class ReadBatchResponse
|
||||||
{
|
{
|
||||||
/// <summary>False if any tag read failed.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>VTQ results in the same order as the request tags.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public List<VtqMessage> Vtqs { get; set; } = [];
|
public List<VtqMessage> Vtqs { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -237,100 +337,71 @@ public class ReadBatchResponse
|
|||||||
// Write
|
// Write
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to write a single tag value.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteRequest
|
public class WriteRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag address to write.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Value as a string (parsed server-side).</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public string Value { get; set; } = string.Empty;
|
public TypedValue? Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a single-tag Write call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteResponse
|
public class WriteResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the write succeeded.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Status or error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
// WriteItem / WriteResult
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>A single tag-value pair for batch write operations.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteItem
|
public class WriteItem
|
||||||
{
|
{
|
||||||
/// <summary>Tag address.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Value as a string.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Value { get; set; } = string.Empty;
|
public TypedValue? Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Per-item result from a batch write operation.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteResult
|
public class WriteResult
|
||||||
{
|
{
|
||||||
/// <summary>Tag address that was written.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Whether the individual write succeeded.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Error message for this item, if any.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
// WriteBatch
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteBatchRequest
|
public class WriteBatchRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag-value pairs to write.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public List<WriteItem> Items { get; set; } = [];
|
public List<WriteItem> Items { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a batch Write call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteBatchResponse
|
public class WriteBatchResponse
|
||||||
{
|
{
|
||||||
/// <summary>Overall success — false if any item failed.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Status or error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Per-item write results.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public List<WriteResult> Results { get; set; } = [];
|
public List<WriteResult> Results { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -339,59 +410,43 @@ public class WriteBatchResponse
|
|||||||
// WriteBatchAndWait
|
// 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]
|
[DataContract]
|
||||||
public class WriteBatchAndWaitRequest
|
public class WriteBatchAndWaitRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag-value pairs to write.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public List<WriteItem> Items { get; set; } = [];
|
public List<WriteItem> Items { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Tag to poll after writes complete.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public string FlagTag { get; set; } = string.Empty;
|
public string FlagTag { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Expected value for the flag tag (string comparison).</summary>
|
|
||||||
[DataMember(Order = 4)]
|
[DataMember(Order = 4)]
|
||||||
public string FlagValue { get; set; } = string.Empty;
|
public TypedValue? FlagValue { get; set; }
|
||||||
|
|
||||||
/// <summary>Timeout in milliseconds (default 5000 if <= 0).</summary>
|
|
||||||
[DataMember(Order = 5)]
|
[DataMember(Order = 5)]
|
||||||
public int TimeoutMs { get; set; }
|
public int TimeoutMs { get; set; }
|
||||||
|
|
||||||
/// <summary>Poll interval in milliseconds (default 100 if <= 0).</summary>
|
|
||||||
[DataMember(Order = 6)]
|
[DataMember(Order = 6)]
|
||||||
public int PollIntervalMs { get; set; }
|
public int PollIntervalMs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from a WriteBatchAndWait call.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class WriteBatchAndWaitResponse
|
public class WriteBatchAndWaitResponse
|
||||||
{
|
{
|
||||||
/// <summary>Overall operation success.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>Status or error message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Per-item write results.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public List<WriteResult> WriteResults { get; set; } = [];
|
public List<WriteResult> WriteResults { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
|
|
||||||
[DataMember(Order = 4)]
|
[DataMember(Order = 4)]
|
||||||
public bool FlagReached { get; set; }
|
public bool FlagReached { get; set; }
|
||||||
|
|
||||||
/// <summary>Total elapsed time in milliseconds.</summary>
|
|
||||||
[DataMember(Order = 5)]
|
[DataMember(Order = 5)]
|
||||||
public int ElapsedMs { get; set; }
|
public int ElapsedMs { get; set; }
|
||||||
}
|
}
|
||||||
@@ -400,19 +455,15 @@ public class WriteBatchAndWaitResponse
|
|||||||
// Subscribe
|
// Subscribe
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class SubscribeRequest
|
public class SubscribeRequest
|
||||||
{
|
{
|
||||||
/// <summary>Valid session ID.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string SessionId { get; set; } = string.Empty;
|
public string SessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Tag addresses to monitor.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Backend sampling interval in milliseconds.</summary>
|
|
||||||
[DataMember(Order = 3)]
|
[DataMember(Order = 3)]
|
||||||
public int SamplingMs { get; set; }
|
public int SamplingMs { get; set; }
|
||||||
}
|
}
|
||||||
@@ -421,24 +472,19 @@ public class SubscribeRequest
|
|||||||
// CheckApiKey
|
// CheckApiKey
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Request to validate an API key without creating a session.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class CheckApiKeyRequest
|
public class CheckApiKeyRequest
|
||||||
{
|
{
|
||||||
/// <summary>API key to validate.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public string ApiKey { get; set; } = string.Empty;
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response from an API key validation check.</summary>
|
|
||||||
[DataContract]
|
[DataContract]
|
||||||
public class CheckApiKeyResponse
|
public class CheckApiKeyResponse
|
||||||
{
|
{
|
||||||
/// <summary>Whether the API key is valid.</summary>
|
|
||||||
[DataMember(Order = 1)]
|
[DataMember(Order = 1)]
|
||||||
public bool IsValid { get; set; }
|
public bool IsValid { get; set; }
|
||||||
|
|
||||||
/// <summary>Validation message.</summary>
|
|
||||||
[DataMember(Order = 2)]
|
[DataMember(Order = 2)]
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Value, Timestamp, and Quality for SCADA data.</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)
|
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 Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
|
||||||
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);
|
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 an Uncertain-quality VTQ with the current UTC time.</summary>
|
public override string ToString() =>
|
||||||
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
|
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
|
||||||
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
|
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<Description>gRPC client library for LmxProxy service</Description>
|
<Description>gRPC client library for LmxProxy SCADA proxy service</Description>
|
||||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
<Platforms>AnyCPU</Platforms>
|
<Platforms>AnyCPU</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -5,34 +5,11 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ConnectionState
|
public enum ConnectionState
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The client is disconnected.
|
|
||||||
/// </summary>
|
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client is in the process of connecting.
|
|
||||||
/// </summary>
|
|
||||||
Connecting,
|
Connecting,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client is connected.
|
|
||||||
/// </summary>
|
|
||||||
Connected,
|
Connected,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client is in the process of disconnecting.
|
|
||||||
/// </summary>
|
|
||||||
Disconnecting,
|
Disconnecting,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client encountered an error.
|
|
||||||
/// </summary>
|
|
||||||
Error,
|
Error,
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client is reconnecting after a connection loss.
|
|
||||||
/// </summary>
|
|
||||||
Reconnecting
|
Reconnecting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConnectionStateChangedEventArgs : EventArgs
|
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,
|
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
|
||||||
string? message = null)
|
string? message = null)
|
||||||
{
|
{
|
||||||
@@ -22,24 +16,9 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
|||||||
Message = message;
|
Message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the previous connection state.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState PreviousState { get; }
|
public ConnectionState PreviousState { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current connection state.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState CurrentState { get; }
|
public ConnectionState CurrentState { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the timestamp when the state change occurred.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Timestamp { get; }
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets additional information about the state change, such as error messages.
|
|
||||||
/// </summary>
|
|
||||||
public string? Message { get; }
|
public string? Message { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,99 +6,62 @@ using System.Threading.Tasks;
|
|||||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface for SCADA system clients.
|
/// Interface for SCADA system clients (MxAccess wrapper).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IScadaClient : IAsyncDisposable
|
public interface IScadaClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>Gets whether the client is connected to MxAccess.</summary>
|
||||||
/// Gets the connection status.
|
|
||||||
/// </summary>
|
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Gets the current connection state.</summary>
|
||||||
/// Gets the current connection state.
|
|
||||||
/// </summary>
|
|
||||||
ConnectionState ConnectionState { get; }
|
ConnectionState ConnectionState { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Occurs when the connection state changes.</summary>
|
||||||
/// Occurs when the connection state changes.
|
|
||||||
/// </summary>
|
|
||||||
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
|
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Connects to MxAccess.</summary>
|
||||||
/// Connects to the SCADA system.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
Task ConnectAsync(CancellationToken ct = default);
|
Task ConnectAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Disconnects from MxAccess.</summary>
|
||||||
/// Disconnects from the SCADA system.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
Task DisconnectAsync(CancellationToken ct = default);
|
Task DisconnectAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Reads a single tag value.</summary>
|
||||||
/// Reads a single tag value from the SCADA system.
|
/// <returns>VTQ with typed value.</returns>
|
||||||
/// </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);
|
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Reads multiple tag values with semaphore-controlled concurrency.</summary>
|
||||||
/// Reads multiple tag values from the SCADA system.
|
/// <returns>Dictionary of address to VTQ.</returns>
|
||||||
/// </summary>
|
Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
|
||||||
/// <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>
|
/// <summary>Writes a single tag value. Value is a native .NET type (not string).</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);
|
Task WriteAsync(string address, object value, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Writes multiple tag values with semaphore-controlled concurrency.</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);
|
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes a batch of tag values and a flag tag, then waits for a response tag to
|
/// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires.
|
||||||
/// equal the expected value.
|
/// Returns (writeSuccess, flagReached, elapsedMs).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">The regular tag values to write.</param>
|
/// <param name="values">Tag-value pairs to write.</param>
|
||||||
/// <param name="flagAddress">The address of the flag tag to write.</param>
|
/// <param name="flagTag">Tag to poll after writes.</param>
|
||||||
/// <param name="flagValue">The value to write to the flag tag.</param>
|
/// <param name="flagValue">Expected value (type-aware comparison).</param>
|
||||||
/// <param name="responseAddress">The address of the response tag to monitor.</param>
|
/// <param name="timeoutMs">Max wait time in milliseconds.</param>
|
||||||
/// <param name="responseValue">The expected value of the response tag.</param>
|
/// <param name="pollIntervalMs">Poll interval in milliseconds.</param>
|
||||||
/// <param name="ct">Cancellation token controlling the wait.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>
|
Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||||
/// <c>true</c> if the response value was observed before cancellation;
|
|
||||||
/// otherwise <c>false</c>.
|
|
||||||
/// </returns>
|
|
||||||
Task<bool> WriteBatchAndWaitAsync(
|
|
||||||
IReadOnlyDictionary<string, object> values,
|
IReadOnlyDictionary<string, object> values,
|
||||||
string flagAddress,
|
string flagTag,
|
||||||
object flagValue,
|
object flagValue,
|
||||||
string responseAddress,
|
int timeoutMs,
|
||||||
object responseValue,
|
int pollIntervalMs,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Subscribes to value changes for specified addresses.</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>
|
/// <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);
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,123 +2,126 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// OPC quality codes mapped to domain-level values.
|
/// OPC quality codes mapped to domain-level values.
|
||||||
/// The byte value matches the low-order byte of the OPC UA StatusCode,
|
/// The byte value matches the low-order byte of the OPC DA quality code,
|
||||||
/// so it can be persisted or round-tripped without translation.
|
/// enabling direct round-trip between the domain enum and the wire OPC DA byte.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum Quality : byte
|
public enum Quality : byte
|
||||||
{
|
{
|
||||||
// ─────────────── Bad family (0-31) ───────────────
|
// ─────────────── Bad family (0-31) ───────────────
|
||||||
/// <summary>0x00 – Bad [Non-Specific]</summary>
|
/// <summary>0x00 - Bad [Non-Specific]</summary>
|
||||||
Bad = 0,
|
Bad = 0,
|
||||||
|
|
||||||
/// <summary>0x01 – Unknown quality value</summary>
|
/// <summary>0x01 - Unknown quality value</summary>
|
||||||
Unknown = 1,
|
Unknown = 1,
|
||||||
|
|
||||||
/// <summary>0x04 – Bad [Configuration Error]</summary>
|
/// <summary>0x04 - Bad [Configuration Error]</summary>
|
||||||
Bad_ConfigError = 4,
|
Bad_ConfigError = 4,
|
||||||
|
|
||||||
/// <summary>0x08 – Bad [Not Connected]</summary>
|
/// <summary>0x08 - Bad [Not Connected]</summary>
|
||||||
Bad_NotConnected = 8,
|
Bad_NotConnected = 8,
|
||||||
|
|
||||||
/// <summary>0x0C – Bad [Device Failure]</summary>
|
/// <summary>0x0C - Bad [Device Failure]</summary>
|
||||||
Bad_DeviceFailure = 12,
|
Bad_DeviceFailure = 12,
|
||||||
|
|
||||||
/// <summary>0x10 – Bad [Sensor Failure]</summary>
|
/// <summary>0x10 - Bad [Sensor Failure]</summary>
|
||||||
Bad_SensorFailure = 16,
|
Bad_SensorFailure = 16,
|
||||||
|
|
||||||
/// <summary>0x14 – Bad [Last Known Value]</summary>
|
/// <summary>0x14 - Bad [Last Known Value]</summary>
|
||||||
Bad_LastKnownValue = 20,
|
Bad_LastKnownValue = 20,
|
||||||
|
|
||||||
/// <summary>0x18 – Bad [Communication Failure]</summary>
|
/// <summary>0x18 - Bad [Communication Failure]</summary>
|
||||||
Bad_CommFailure = 24,
|
Bad_CommFailure = 24,
|
||||||
|
|
||||||
/// <summary>0x1C – Bad [Out of Service]</summary>
|
/// <summary>0x1C - Bad [Out of Service]</summary>
|
||||||
Bad_OutOfService = 28,
|
Bad_OutOfService = 28,
|
||||||
|
|
||||||
|
/// <summary>0x20 - Bad [Waiting for Initial Data]</summary>
|
||||||
|
Bad_WaitingForInitialData = 32,
|
||||||
|
|
||||||
// ──────────── Uncertain family (64-95) ───────────
|
// ──────────── Uncertain family (64-95) ───────────
|
||||||
/// <summary>0x40 – Uncertain [Non-Specific]</summary>
|
/// <summary>0x40 - Uncertain [Non-Specific]</summary>
|
||||||
Uncertain = 64,
|
Uncertain = 64,
|
||||||
|
|
||||||
/// <summary>0x41 – Uncertain [Non-Specific] (Low Limited)</summary>
|
/// <summary>0x41 - Uncertain [Non-Specific] (Low Limited)</summary>
|
||||||
Uncertain_LowLimited = 65,
|
Uncertain_LowLimited = 65,
|
||||||
|
|
||||||
/// <summary>0x42 – Uncertain [Non-Specific] (High Limited)</summary>
|
/// <summary>0x42 - Uncertain [Non-Specific] (High Limited)</summary>
|
||||||
Uncertain_HighLimited = 66,
|
Uncertain_HighLimited = 66,
|
||||||
|
|
||||||
/// <summary>0x43 – Uncertain [Non-Specific] (Constant)</summary>
|
/// <summary>0x43 - Uncertain [Non-Specific] (Constant)</summary>
|
||||||
Uncertain_Constant = 67,
|
Uncertain_Constant = 67,
|
||||||
|
|
||||||
/// <summary>0x44 – Uncertain [Last Usable]</summary>
|
/// <summary>0x44 - Uncertain [Last Usable]</summary>
|
||||||
Uncertain_LastUsable = 68,
|
Uncertain_LastUsable = 68,
|
||||||
|
|
||||||
/// <summary>0x45 – Uncertain [Last Usable] (Low Limited)</summary>
|
/// <summary>0x45 - Uncertain [Last Usable] (Low Limited)</summary>
|
||||||
Uncertain_LastUsable_LL = 69,
|
Uncertain_LastUsable_LL = 69,
|
||||||
|
|
||||||
/// <summary>0x46 – Uncertain [Last Usable] (High Limited)</summary>
|
/// <summary>0x46 - Uncertain [Last Usable] (High Limited)</summary>
|
||||||
Uncertain_LastUsable_HL = 70,
|
Uncertain_LastUsable_HL = 70,
|
||||||
|
|
||||||
/// <summary>0x47 – Uncertain [Last Usable] (Constant)</summary>
|
/// <summary>0x47 - Uncertain [Last Usable] (Constant)</summary>
|
||||||
Uncertain_LastUsable_Cnst = 71,
|
Uncertain_LastUsable_Cnst = 71,
|
||||||
|
|
||||||
/// <summary>0x50 – Uncertain [Sensor Not Accurate]</summary>
|
/// <summary>0x50 - Uncertain [Sensor Not Accurate]</summary>
|
||||||
Uncertain_SensorNotAcc = 80,
|
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,
|
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,
|
Uncertain_SensorNotAcc_HL = 82,
|
||||||
|
|
||||||
/// <summary>0x53 – Uncertain [Sensor Not Accurate] (Constant)</summary>
|
/// <summary>0x53 - Uncertain [Sensor Not Accurate] (Constant)</summary>
|
||||||
Uncertain_SensorNotAcc_C = 83,
|
Uncertain_SensorNotAcc_C = 83,
|
||||||
|
|
||||||
/// <summary>0x54 – Uncertain [EU Exceeded]</summary>
|
/// <summary>0x54 - Uncertain [EU Exceeded]</summary>
|
||||||
Uncertain_EuExceeded = 84,
|
Uncertain_EuExceeded = 84,
|
||||||
|
|
||||||
/// <summary>0x55 – Uncertain [EU Exceeded] (Low Limited)</summary>
|
/// <summary>0x55 - Uncertain [EU Exceeded] (Low Limited)</summary>
|
||||||
Uncertain_EuExceeded_LL = 85,
|
Uncertain_EuExceeded_LL = 85,
|
||||||
|
|
||||||
/// <summary>0x56 – Uncertain [EU Exceeded] (High Limited)</summary>
|
/// <summary>0x56 - Uncertain [EU Exceeded] (High Limited)</summary>
|
||||||
Uncertain_EuExceeded_HL = 86,
|
Uncertain_EuExceeded_HL = 86,
|
||||||
|
|
||||||
/// <summary>0x57 – Uncertain [EU Exceeded] (Constant)</summary>
|
/// <summary>0x57 - Uncertain [EU Exceeded] (Constant)</summary>
|
||||||
Uncertain_EuExceeded_C = 87,
|
Uncertain_EuExceeded_C = 87,
|
||||||
|
|
||||||
/// <summary>0x58 – Uncertain [Sub-Normal]</summary>
|
/// <summary>0x58 - Uncertain [Sub-Normal]</summary>
|
||||||
Uncertain_SubNormal = 88,
|
Uncertain_SubNormal = 88,
|
||||||
|
|
||||||
/// <summary>0x59 – Uncertain [Sub-Normal] (Low Limited)</summary>
|
/// <summary>0x59 - Uncertain [Sub-Normal] (Low Limited)</summary>
|
||||||
Uncertain_SubNormal_LL = 89,
|
Uncertain_SubNormal_LL = 89,
|
||||||
|
|
||||||
/// <summary>0x5A – Uncertain [Sub-Normal] (High Limited)</summary>
|
/// <summary>0x5A - Uncertain [Sub-Normal] (High Limited)</summary>
|
||||||
Uncertain_SubNormal_HL = 90,
|
Uncertain_SubNormal_HL = 90,
|
||||||
|
|
||||||
/// <summary>0x5B – Uncertain [Sub-Normal] (Constant)</summary>
|
/// <summary>0x5B - Uncertain [Sub-Normal] (Constant)</summary>
|
||||||
Uncertain_SubNormal_C = 91,
|
Uncertain_SubNormal_C = 91,
|
||||||
|
|
||||||
// ─────────────── Good family (192-219) ────────────
|
// ─────────────── Good family (192-219) ────────────
|
||||||
/// <summary>0xC0 – Good [Non-Specific]</summary>
|
/// <summary>0xC0 - Good [Non-Specific]</summary>
|
||||||
Good = 192,
|
Good = 192,
|
||||||
|
|
||||||
/// <summary>0xC1 – Good [Non-Specific] (Low Limited)</summary>
|
/// <summary>0xC1 - Good [Non-Specific] (Low Limited)</summary>
|
||||||
Good_LowLimited = 193,
|
Good_LowLimited = 193,
|
||||||
|
|
||||||
/// <summary>0xC2 – Good [Non-Specific] (High Limited)</summary>
|
/// <summary>0xC2 - Good [Non-Specific] (High Limited)</summary>
|
||||||
Good_HighLimited = 194,
|
Good_HighLimited = 194,
|
||||||
|
|
||||||
/// <summary>0xC3 – Good [Non-Specific] (Constant)</summary>
|
/// <summary>0xC3 - Good [Non-Specific] (Constant)</summary>
|
||||||
Good_Constant = 195,
|
Good_Constant = 195,
|
||||||
|
|
||||||
/// <summary>0xD8 – Good [Local Override]</summary>
|
/// <summary>0xD8 - Good [Local Override]</summary>
|
||||||
Good_LocalOverride = 216,
|
Good_LocalOverride = 216,
|
||||||
|
|
||||||
/// <summary>0xD9 – Good [Local Override] (Low Limited)</summary>
|
/// <summary>0xD9 - Good [Local Override] (Low Limited)</summary>
|
||||||
Good_LocalOverride_LL = 217,
|
Good_LocalOverride_LL = 217,
|
||||||
|
|
||||||
/// <summary>0xDA – Good [Local Override] (High Limited)</summary>
|
/// <summary>0xDA - Good [Local Override] (High Limited)</summary>
|
||||||
Good_LocalOverride_HL = 218,
|
Good_LocalOverride_HL = 218,
|
||||||
|
|
||||||
/// <summary>0xDB – Good [Local Override] (Constant)</summary>
|
/// <summary>0xDB - Good [Local Override] (Constant)</summary>
|
||||||
Good_LocalOverride_C = 219
|
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>
|
/// </summary>
|
||||||
public readonly struct Vtq : IEquatable<Vtq>
|
public readonly struct Vtq : IEquatable<Vtq>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>Gets the value. Null represents an unset/missing value.</summary>
|
||||||
/// Gets the value.
|
|
||||||
/// </summary>
|
|
||||||
public object? Value { get; }
|
public object? Value { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Gets the UTC timestamp when the value was read.</summary>
|
||||||
/// Gets the timestamp when the value was read.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Timestamp { get; }
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Gets the quality of the value.</summary>
|
||||||
/// Gets the quality of the value.
|
|
||||||
/// </summary>
|
|
||||||
public Quality Quality { get; }
|
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)
|
public Vtq(object? value, DateTime timestamp, Quality quality)
|
||||||
{
|
{
|
||||||
Value = value;
|
Value = value;
|
||||||
@@ -35,63 +23,17 @@ namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
|||||||
Quality = quality;
|
Quality = quality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
|
||||||
/// Creates a new <see cref="Vtq" /> instance with the specified value and quality, using the current UTC timestamp.
|
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
|
||||||
/// </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);
|
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);
|
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);
|
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) =>
|
public bool Equals(Vtq other) =>
|
||||||
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
|
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);
|
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()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
unchecked
|
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() =>
|
public override string ToString() =>
|
||||||
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
|
$"{{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);
|
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);
|
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,71 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc";
|
|
||||||
|
|
||||||
package scada;
|
package scada;
|
||||||
|
|
||||||
// The SCADA service definition
|
// ============================================================
|
||||||
|
// Service Definition
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
service ScadaService {
|
service ScadaService {
|
||||||
// Connection management
|
|
||||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||||
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
||||||
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
||||||
|
|
||||||
// Read operations
|
|
||||||
rpc Read(ReadRequest) returns (ReadResponse);
|
rpc Read(ReadRequest) returns (ReadResponse);
|
||||||
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
||||||
|
|
||||||
// Write operations
|
|
||||||
rpc Write(WriteRequest) returns (WriteResponse);
|
rpc Write(WriteRequest) returns (WriteResponse);
|
||||||
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
||||||
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
||||||
|
|
||||||
// Subscription operations (server streaming) - now streams VtqMessage directly
|
|
||||||
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
||||||
|
|
||||||
// Authentication
|
|
||||||
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
|
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 {
|
message ConnectRequest {
|
||||||
string client_id = 1;
|
string client_id = 1;
|
||||||
@@ -59,16 +97,29 @@ message GetConnectionStateResponse {
|
|||||||
int64 connected_since_utc_ticks = 3;
|
int64 connected_since_utc_ticks = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === VTQ MESSAGE ===
|
message CheckApiKeyRequest {
|
||||||
|
string api_key = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CheckApiKeyResponse {
|
||||||
|
bool is_valid = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Value-Timestamp-Quality
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
message VtqMessage {
|
message VtqMessage {
|
||||||
string tag = 1;
|
string tag = 1;
|
||||||
string value = 2;
|
TypedValue value = 2;
|
||||||
int64 timestamp_utc_ticks = 3;
|
int64 timestamp_utc_ticks = 3;
|
||||||
string quality = 4; // "Good", "Uncertain", "Bad"
|
QualityCode quality = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === READ MESSAGES ===
|
// ============================================================
|
||||||
|
// Read Operations
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
message ReadRequest {
|
message ReadRequest {
|
||||||
string session_id = 1;
|
string session_id = 1;
|
||||||
@@ -92,12 +143,14 @@ message ReadBatchResponse {
|
|||||||
repeated VtqMessage vtqs = 3;
|
repeated VtqMessage vtqs = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === WRITE MESSAGES ===
|
// ============================================================
|
||||||
|
// Write Operations
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
message WriteRequest {
|
message WriteRequest {
|
||||||
string session_id = 1;
|
string session_id = 1;
|
||||||
string tag = 2;
|
string tag = 2;
|
||||||
string value = 3;
|
TypedValue value = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WriteResponse {
|
message WriteResponse {
|
||||||
@@ -107,7 +160,7 @@ message WriteResponse {
|
|||||||
|
|
||||||
message WriteItem {
|
message WriteItem {
|
||||||
string tag = 1;
|
string tag = 1;
|
||||||
string value = 2;
|
TypedValue value = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WriteResult {
|
message WriteResult {
|
||||||
@@ -127,11 +180,15 @@ message WriteBatchResponse {
|
|||||||
repeated WriteResult results = 3;
|
repeated WriteResult results = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WriteBatchAndWait
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
message WriteBatchAndWaitRequest {
|
message WriteBatchAndWaitRequest {
|
||||||
string session_id = 1;
|
string session_id = 1;
|
||||||
repeated WriteItem items = 2;
|
repeated WriteItem items = 2;
|
||||||
string flag_tag = 3;
|
string flag_tag = 3;
|
||||||
string flag_value = 4;
|
TypedValue flag_value = 4;
|
||||||
int32 timeout_ms = 5;
|
int32 timeout_ms = 5;
|
||||||
int32 poll_interval_ms = 6;
|
int32 poll_interval_ms = 6;
|
||||||
}
|
}
|
||||||
@@ -144,23 +201,12 @@ message WriteBatchAndWaitResponse {
|
|||||||
int32 elapsed_ms = 5;
|
int32 elapsed_ms = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === SUBSCRIPTION MESSAGES ===
|
// ============================================================
|
||||||
|
// Subscription
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
message SubscribeRequest {
|
message SubscribeRequest {
|
||||||
string session_id = 1;
|
string session_id = 1;
|
||||||
repeated string tags = 2;
|
repeated string tags = 2;
|
||||||
int32 sampling_ms = 3;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
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
|
// Placeholder - Phase 3 will implement full Topshelf startup.
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,35 +8,37 @@
|
|||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
|
||||||
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
|
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
|
||||||
<!-- Force x86 architecture for all configurations (required by ArchestrA.MXAccess) -->
|
|
||||||
<PlatformTarget>x86</PlatformTarget>
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
<Platforms>x86</Platforms>
|
<Platforms>x86</Platforms>
|
||||||
<Prefer32Bit>true</Prefer32Bit>
|
<Prefer32Bit>true</Prefer32Bit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.Core" Version="2.46.6"/>
|
<PackageReference Include="Grpc.Core" Version="2.46.6" />
|
||||||
<PackageReference Include="Grpc.Tools" Version="2.51.0">
|
<PackageReference Include="Grpc.Tools" Version="2.68.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.21.12"/>
|
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||||
<PackageReference Include="Topshelf" Version="4.3.0"/>
|
<PackageReference Include="Topshelf" Version="4.3.0" />
|
||||||
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
|
<PackageReference Include="Topshelf.Serilog" Version="4.3.0" />
|
||||||
<PackageReference Include="Serilog" Version="2.10.0"/>
|
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
|
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0"/>
|
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
|
||||||
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0"/>
|
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
|
||||||
<PackageReference Include="System.Threading.Channels" Version="4.7.1"/>
|
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32"/>
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32"/>
|
<PackageReference Include="System.Threading.Channels" Version="4.7.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32"/>
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32"/>
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32" />
|
||||||
<PackageReference Include="Polly" Version="7.2.4"/>
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32"/>
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32" />
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
<PackageReference Include="Polly" Version="7.2.4" />
|
||||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1"/>
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both"/>
|
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -57,9 +59,6 @@
|
|||||||
<None Update="appsettings.*.json">
|
<None Update="appsettings.*.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="App.config">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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