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

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

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Per-client subscription statistics.
/// </summary>
public class ClientStats
{
/// <summary>
/// Gets or sets the number of tags the client is subscribed to.
/// </summary>
public int SubscribedTags { get; set; }
/// <summary>
/// Gets or sets the number of delivered messages.
/// </summary>
public long DeliveredMessages { get; set; }
/// <summary>
/// Gets or sets the number of dropped messages.
/// </summary>
public long DroppedMessages { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Subscription statistics for all clients and tags.
/// </summary>
public class SubscriptionStats
{
/// <summary>
/// Gets or sets the total number of clients.
/// </summary>
public int TotalClients { get; set; }
/// <summary>
/// Gets or sets the total number of tags.
/// </summary>
public int TotalTags { get; set; }
/// <summary>
/// Gets or sets the mapping of tag addresses to client counts.
/// </summary>
public Dictionary<string, int> TagClientCounts { get; set; } = new();
/// <summary>
/// Gets or sets the mapping of client IDs to their statistics.
/// </summary>
public Dictionary<string, ClientStats> ClientStats { get; set; } = new();
}
}

View File

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