# Phase 5: Client Core — Implementation Plan **Date**: 2026-03-21 **Prerequisites**: Phase 1 complete and passing (Protocol & Domain Types — `ScadaContracts.cs` with v2 `TypedValue`/`QualityCode` messages, `Quality.cs`, `QualityExtensions.cs`, `Vtq.cs`, `ConnectionState.cs` all exist and cross-stack serialization tests pass) **Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` ## Guardrails 1. **Client targets .NET 10, AnyCPU** — use latest C# features freely. The csproj `` is `net10.0`, `latest`. 2. **Code-first gRPC only** — the Client uses `protobuf-net.Grpc` with `[ServiceContract]`/`[DataContract]` attributes. Never reference proto files or `Grpc.Tools`. 3. **No string serialization heuristics** — v2 uses native `TypedValue`. Do not write `double.TryParse`, `bool.TryParse`, or any string-to-value parsing on tag values. 4. **`status_code` is canonical for quality** — `symbolic_name` is derived. Never set `symbolic_name` independently. 5. **Polly v8 API** — the Client csproj already has ``. Use the v8 `ResiliencePipeline` API, not the legacy v7 `IAsyncPolicy` API. 6. **No new NuGet packages** — all needed packages are already in `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj`. 7. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client` 8. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests` 9. **Namespace root**: `ZB.MOM.WW.LmxProxy.Client` ## Step 1: ClientTlsConfiguration **File**: `src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs` This file already exists with the correct shape. Verify it has all these properties (from Component-Client.md): ```csharp namespace ZB.MOM.WW.LmxProxy.Client; public class ClientTlsConfiguration { public bool UseTls { get; set; } = false; public string? ClientCertificatePath { get; set; } public string? ClientKeyPath { get; set; } public string? ServerCaCertificatePath { get; set; } public string? ServerNameOverride { get; set; } public bool ValidateServerCertificate { get; set; } = true; public bool AllowSelfSignedCertificates { get; set; } = false; public bool IgnoreAllCertificateErrors { get; set; } = false; } ``` If it matches, no changes needed. If any properties are missing, add them. ## Step 2: Security/GrpcChannelFactory **File**: `src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs` This file already exists. Verify the implementation covers: 1. `CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)` — returns `GrpcChannel`. 2. Creates `SocketsHttpHandler` with `EnableMultipleHttp2Connections = true`. 3. For TLS: sets `SslProtocols = Tls12 | Tls13`, configures `ServerNameOverride` as `TargetHost`, loads client certificate from PEM files for mTLS. 4. Certificate validation callback handles: `IgnoreAllCertificateErrors`, `!ValidateServerCertificate`, custom CA trust store via `ServerCaCertificatePath`, `AllowSelfSignedCertificates`. 5. Static constructor sets `System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport = true` for non-TLS. The existing implementation matches. No changes expected unless Phase 1 introduced breaking changes. ## Step 3: ILmxProxyClient Interface **File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs` Rewrite for v2 protocol. The key changes from v1: - `WriteAsync` and `WriteBatchAsync` accept `TypedValue` instead of `object` - `SubscribeAsync` has an `onStreamError` callback parameter - `CheckApiKeyAsync` is added - Return types use v2 domain `Vtq` (which wraps `TypedValue` + `QualityCode`) ```csharp using ZB.MOM.WW.LmxProxy.Client.Domain; namespace ZB.MOM.WW.LmxProxy.Client; /// /// Interface for LmxProxy client operations. /// public interface ILmxProxyClient : IDisposable, IAsyncDisposable { /// Gets or sets the default timeout for operations (range: 1s to 10min). TimeSpan DefaultTimeout { get; set; } /// Connects to the LmxProxy service and establishes a session. Task ConnectAsync(CancellationToken cancellationToken = default); /// Disconnects from the LmxProxy service. Task DisconnectAsync(); /// Returns true if the client has an active session. Task IsConnectedAsync(); /// Reads a single tag value. Task ReadAsync(string address, CancellationToken cancellationToken = default); /// Reads multiple tag values in a single batch. Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); /// Writes a single tag value (native TypedValue — no string heuristics). Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default); /// Writes multiple tag values in a single batch. Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); /// /// Writes a batch of values, then polls a flag tag until it matches or timeout expires. /// Returns (writeResults, flagReached, elapsedMs). /// Task WriteBatchAndWaitAsync( IDictionary values, string flagTag, TypedValue flagValue, int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default); /// Subscribes to tag updates with value and error callbacks. Task SubscribeAsync( IEnumerable addresses, Action onUpdate, Action? onStreamError = null, CancellationToken cancellationToken = default); /// Validates an API key and returns info. Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default); /// Returns a snapshot of client-side metrics. Dictionary GetMetrics(); } ``` **Note**: The `TypedValue` class referenced here is from `Domain/ScadaContracts.cs` — it should already have been updated in Phase 1 to use `[DataContract]` with the v2 oneof-style properties (e.g., `BoolValue`, `Int32Value`, `DoubleValue`, `StringValue`, `DatetimeValue`, etc., with a `ValueCase` enum or similar discriminator). ## Step 4: LmxProxyClient — Main File **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs` This is a partial class. The main file contains the constructor, fields, properties, and the Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey methods. ### 4.1 Fields and Constructor ```csharp public partial class LmxProxyClient : ILmxProxyClient { private readonly ILogger _logger; private readonly string _host; private readonly int _port; private readonly string? _apiKey; private readonly ClientTlsConfiguration? _tlsConfiguration; private readonly ClientMetrics _metrics = new(); private readonly SemaphoreSlim _connectionLock = new(1, 1); private readonly List _activeSubscriptions = []; private readonly Lock _subscriptionLock = new(); private GrpcChannel? _channel; private IScadaService? _client; private string _sessionId = string.Empty; private bool _disposed; private bool _isConnected; private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); private ClientConfiguration? _configuration; private ResiliencePipeline? _resiliencePipeline; // Polly v8 private Timer? _keepAliveTimer; private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30); // IsConnected computed property public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); public LmxProxyClient( string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger? logger = null) { _host = host ?? throw new ArgumentNullException(nameof(host)); _port = port; _apiKey = apiKey; _tlsConfiguration = tlsConfiguration; _logger = logger ?? NullLogger.Instance; } internal void SetBuilderConfiguration(ClientConfiguration config) { _configuration = config; // Build Polly v8 ResiliencePipeline from config if (config.MaxRetryAttempts > 0) { _resiliencePipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = config.MaxRetryAttempts, Delay = config.RetryDelay, BackoffType = DelayBackoffType.Exponential, ShouldHandle = new PredicateBuilder() .Handle(ex => ex.StatusCode == StatusCode.Unavailable || ex.StatusCode == StatusCode.DeadlineExceeded || ex.StatusCode == StatusCode.ResourceExhausted || ex.StatusCode == StatusCode.Aborted), OnRetry = args => { _logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}", args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message); return ValueTask.CompletedTask; } }) .Build(); } } } ``` ### 4.2 ReadAsync ```csharp public async Task ReadAsync(string address, CancellationToken cancellationToken = default) { EnsureConnected(); _metrics.IncrementOperationCount("Read"); var sw = Stopwatch.StartNew(); try { var request = new ReadRequest { SessionId = _sessionId, Tag = address }; ReadResponse response = await ExecuteWithRetry( () => _client!.ReadAsync(request).AsTask(), cancellationToken); if (!response.Success) throw new InvalidOperationException($"Read failed: {response.Message}"); return ConvertVtqMessage(response.Vtq); } catch (Exception ex) { _metrics.IncrementErrorCount("Read"); throw; } finally { sw.Stop(); _metrics.RecordLatency("Read", sw.ElapsedMilliseconds); } } ``` ### 4.3 ReadBatchAsync ```csharp public async Task> ReadBatchAsync( IEnumerable addresses, CancellationToken cancellationToken = default) { EnsureConnected(); _metrics.IncrementOperationCount("ReadBatch"); var sw = Stopwatch.StartNew(); try { var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() }; ReadBatchResponse response = await ExecuteWithRetry( () => _client!.ReadBatchAsync(request).AsTask(), cancellationToken); var result = new Dictionary(); foreach (var vtqMsg in response.Vtqs) { result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg); } return result; } catch { _metrics.IncrementErrorCount("ReadBatch"); throw; } finally { sw.Stop(); _metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds); } } ``` ### 4.4 WriteAsync ```csharp public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) { EnsureConnected(); _metrics.IncrementOperationCount("Write"); var sw = Stopwatch.StartNew(); try { var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value }; WriteResponse response = await ExecuteWithRetry( () => _client!.WriteAsync(request).AsTask(), cancellationToken); if (!response.Success) throw new InvalidOperationException($"Write failed: {response.Message}"); } catch { _metrics.IncrementErrorCount("Write"); throw; } finally { sw.Stop(); _metrics.RecordLatency("Write", sw.ElapsedMilliseconds); } } ``` ### 4.5 WriteBatchAsync ```csharp public async Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) { EnsureConnected(); _metrics.IncrementOperationCount("WriteBatch"); var sw = Stopwatch.StartNew(); try { var request = new WriteBatchRequest { SessionId = _sessionId, Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList() }; WriteBatchResponse response = await ExecuteWithRetry( () => _client!.WriteBatchAsync(request).AsTask(), cancellationToken); if (!response.Success) throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); } catch { _metrics.IncrementErrorCount("WriteBatch"); throw; } finally { sw.Stop(); _metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds); } } ``` ### 4.6 WriteBatchAndWaitAsync ```csharp public async Task WriteBatchAndWaitAsync( IDictionary values, string flagTag, TypedValue flagValue, int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default) { EnsureConnected(); var request = new WriteBatchAndWaitRequest { SessionId = _sessionId, Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(), FlagTag = flagTag, FlagValue = flagValue, TimeoutMs = timeoutMs, PollIntervalMs = pollIntervalMs }; return await ExecuteWithRetry( () => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken); } ``` ### 4.7 CheckApiKeyAsync ```csharp public async Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) { EnsureConnected(); var request = new CheckApiKeyRequest { ApiKey = apiKey }; CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request); return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message }; } ``` ### 4.8 ConvertVtqMessage helper This converts the wire `VtqMessage` (v2 with `TypedValue` + `QualityCode`) to the domain `Vtq`: ```csharp private static Vtq ConvertVtqMessage(VtqMessage? msg) { if (msg is null) return new Vtq(null, DateTime.UtcNow, Quality.Bad); object? value = ExtractTypedValue(msg.Value); DateTime timestamp = msg.TimestampUtcTicks > 0 ? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow; Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u); return new Vtq(value, timestamp, quality); } private static object? ExtractTypedValue(TypedValue? tv) { if (tv is null) return null; // Switch on whichever oneof-style property is set // The exact property names depend on the Phase 1 code-first contract design // e.g., tv.BoolValue, tv.Int32Value, tv.DoubleValue, tv.StringValue, etc. // Return the native .NET value directly — no string conversions ... } ``` **Important**: The exact shape of `TypedValue` in code-first contracts depends on Phase 1's implementation. Phase 1 should have defined a discriminator pattern (e.g., `ValueCase` enum or nullable properties with a convention). Adapt `ExtractTypedValue` to whatever pattern was chosen. The key rule: **no string heuristics**. ### 4.9 ExecuteWithRetry helper ```csharp private async Task ExecuteWithRetry(Func> operation, CancellationToken ct) { if (_resiliencePipeline is not null) { return await _resiliencePipeline.ExecuteAsync( async token => await operation(), ct); } return await operation(); } ``` ### 4.10 EnsureConnected, Dispose, DisposeAsync ```csharp private void EnsureConnected() { ObjectDisposedException.ThrowIf(_disposed, this); if (!IsConnected) throw new InvalidOperationException("Client is not connected. Call ConnectAsync first."); } public void Dispose() { if (_disposed) return; _disposed = true; _keepAliveTimer?.Dispose(); _channel?.Dispose(); _connectionLock.Dispose(); } public async ValueTask DisposeAsync() { if (_disposed) return; try { await DisconnectAsync(); } catch { /* swallow */ } Dispose(); } ``` ### 4.11 IsConnectedAsync ```csharp public Task IsConnectedAsync() => Task.FromResult(IsConnected); ``` ### 4.12 GetMetrics ```csharp public Dictionary GetMetrics() => _metrics.GetSnapshot(); ``` ### 4.13 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" ``` ## Step 5: LmxProxyClient.Connection **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs` Partial class containing `ConnectAsync`, `DisconnectAsync`, keep-alive, `MarkDisconnectedAsync`, `BuildEndpointUri`. ### 5.1 ConnectAsync 1. Acquire `_connectionLock`. 2. Throw `ObjectDisposedException` if disposed. 3. Return early if already connected. 4. Build endpoint URI via `BuildEndpointUri()`. 5. Create channel: `GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger)`. 6. Create code-first client: `channel.CreateGrpcService()` (from `ProtoBuf.Grpc.Client`). 7. Send `ConnectRequest` with `ClientId = $"ScadaBridge-{Guid.NewGuid():N}"` and `ApiKey = _apiKey ?? string.Empty`. 8. If `!response.Success`, dispose channel and throw. 9. Store channel, client, sessionId. Set `_isConnected = true`. 10. Call `StartKeepAlive()`. 11. On failure, reset all state and rethrow. 12. Release lock in `finally`. ### 5.2 DisconnectAsync 1. Acquire `_connectionLock`. 2. Stop keep-alive. 3. If client and session exist, send `DisconnectRequest`. Swallow exceptions. 4. Clear client, sessionId, isConnected. Dispose channel. 5. Release lock. ### 5.3 Keep-alive timer - `StartKeepAlive()`: creates `Timer` with `_keepAliveInterval` (30s) interval. - Timer callback: sends `GetConnectionStateRequest`. On failure: stops timer, calls `MarkDisconnectedAsync(ex)`. - `StopKeepAlive()`: disposes timer, nulls it. ### 5.4 MarkDisconnectedAsync 1. If disposed, return. 2. Acquire `_connectionLock`, set `_isConnected = false`, clear client/sessionId, dispose channel. Release lock. 3. Copy and clear `_activeSubscriptions` under `_subscriptionLock`. 4. Dispose each subscription (swallow errors). 5. Log warning with the exception. ### 5.5 BuildEndpointUri ```csharp private Uri BuildEndpointUri() { string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri; } ``` ### 5.6 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" ``` ## Step 6: LmxProxyClient.CodeFirstSubscription **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs` Nested class inside `LmxProxyClient` implementing `ISubscription`. ### 6.1 CodeFirstSubscription class ```csharp private class CodeFirstSubscription : ISubscription { private readonly IScadaService _client; private readonly string _sessionId; private readonly List _tags; private readonly Action _onUpdate; private readonly Action? _onStreamError; private readonly ILogger _logger; private readonly Action? _onDispose; private readonly CancellationTokenSource _cts = new(); private Task? _processingTask; private bool _disposed; private bool _streamErrorFired; ``` Constructor takes all of these. `StartAsync` stores `_processingTask = ProcessUpdatesAsync(cancellationToken)`. ### 6.2 ProcessUpdatesAsync ```csharp private async Task ProcessUpdatesAsync(CancellationToken cancellationToken) { try { var request = new SubscribeRequest { SessionId = _sessionId, Tags = _tags, SamplingMs = 1000 }; using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); await foreach (VtqMessage vtqMsg in _client.SubscribeAsync(request, linkedCts.Token)) { try { Vtq vtq = ConvertVtqMessage(vtqMsg); // static method from outer class _onUpdate(vtqMsg.Tag, vtq); } catch (Exception ex) { _logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag); } } } catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested) { _logger.LogDebug("Subscription cancelled"); } catch (Exception ex) { _logger.LogError(ex, "Error in subscription processing"); FireStreamError(ex); } finally { if (!_disposed) { _disposed = true; _onDispose?.Invoke(this); } } } private void FireStreamError(Exception ex) { if (_streamErrorFired) return; _streamErrorFired = true; try { _onStreamError?.Invoke(ex); } catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); } } ``` **Key difference from v1**: The `ConvertVtqMessage` now handles `TypedValue` + `QualityCode` natively instead of parsing strings. Also, `_onStreamError` callback is invoked exactly once on stream termination (per Component-Client.md section 5.1). ### 6.3 DisposeAsync and Dispose `DisposeAsync()`: Cancel CTS, await `_processingTask` (swallow errors), dispose CTS. 5-second timeout guard. `Dispose()`: Calls `DisposeAsync()` synchronously with `Task.Wait(TimeSpan.FromSeconds(5))`. ### 6.4 Verify build ```bash ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" ``` ## Step 7: LmxProxyClient.ClientMetrics **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs` Internal class. Already exists in v1 reference. Rewrite for v2 with p99 support. ```csharp internal class ClientMetrics { private readonly ConcurrentDictionary _operationCounts = new(); private readonly ConcurrentDictionary _errorCounts = new(); private readonly ConcurrentDictionary> _latencies = new(); private readonly Lock _latencyLock = new(); public void IncrementOperationCount(string operation) { ... } public void IncrementErrorCount(string operation) { ... } public void RecordLatency(string operation, long milliseconds) { ... } public Dictionary GetSnapshot() { ... } } ``` `RecordLatency`: Under `_latencyLock`, add to list. If count > 1000, `RemoveAt(0)`. `GetSnapshot`: Returns dictionary with keys `{op}_count`, `{op}_errors`, `{op}_avg_latency_ms`, `{op}_p95_latency_ms`, `{op}_p99_latency_ms`. `GetPercentile(List values, int percentile)`: Sort, compute index as `(int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1`, clamp with `Math.Max(0, ...)`. ## Step 8: LmxProxyClient.ApiKeyInfo **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs` Simple DTO returned by `CheckApiKeyAsync`: ```csharp namespace ZB.MOM.WW.LmxProxy.Client; public partial class LmxProxyClient { /// /// Result of an API key validation check. /// public class ApiKeyInfo { public bool IsValid { get; init; } public string? Role { get; init; } public string? Description { get; init; } } } ``` ## Step 9: LmxProxyClient.ISubscription **File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs` ```csharp namespace ZB.MOM.WW.LmxProxy.Client; public partial class LmxProxyClient { /// /// Represents an active tag subscription. Dispose to unsubscribe. /// public interface ISubscription : IDisposable { /// Asynchronous disposal with cancellation support. Task DisposeAsync(); } } ``` ## Step 10: Unit Tests **Project**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` Create if not exists: ```bash ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.Tests -o tests/ZB.MOM.WW.LmxProxy.Client.Tests --framework net10.0" ``` **Csproj** for `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj`: - `net10.0` - `` - `` - `` - `` - `` **Add to solution** `ZB.MOM.WW.LmxProxy.slnx`: ```xml ``` ### 10.1 Connection Lifecycle Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs` Mock `IScadaService` using NSubstitute. ```csharp public class LmxProxyClientConnectionTests { [Fact] public async Task ConnectAsync_EstablishesSessionAndStartsKeepAlive() [Fact] public async Task ConnectAsync_ThrowsWhenServerReturnsFailure() [Fact] public async Task DisconnectAsync_SendsDisconnectAndClearsState() [Fact] public async Task IsConnectedAsync_ReturnsFalseBeforeConnect() [Fact] public async Task IsConnectedAsync_ReturnsTrueAfterConnect() [Fact] public async Task KeepAliveFailure_MarksDisconnected() } ``` Note: Testing the keep-alive requires either waiting 30s (too slow) or making the interval configurable for tests. Consider passing the interval as an internal constructor parameter or using a test-only subclass. Alternatively, test `MarkDisconnectedAsync` directly. ### 10.2 Read/Write Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs` ```csharp public class LmxProxyClientReadWriteTests { [Fact] public async Task ReadAsync_ReturnsVtqFromResponse() // Mock ReadAsync to return a VtqMessage with TypedValue.DoubleValue = 42.5 // Verify returned Vtq.Value is 42.5 (double) [Fact] public async Task ReadAsync_ThrowsOnFailureResponse() [Fact] public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs() [Fact] public async Task WriteAsync_SendsTypedValueDirectly() // Verify the WriteRequest.Value is the TypedValue passed in, not a string [Fact] public async Task WriteBatchAsync_SendsAllItems() [Fact] public async Task WriteBatchAndWaitAsync_ReturnsResponse() } ``` ### 10.3 Subscription Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs` ```csharp public class LmxProxyClientSubscriptionTests { [Fact] public async Task SubscribeAsync_InvokesCallbackForEachUpdate() [Fact] public async Task SubscribeAsync_InvokesStreamErrorOnFailure() [Fact] public async Task SubscribeAsync_DisposeStopsProcessing() } ``` ### 10.4 TypedValue Conversion Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs` ```csharp public class TypedValueConversionTests { [Fact] public void ConvertVtqMessage_ExtractsBoolValue() [Fact] public void ConvertVtqMessage_ExtractsInt32Value() [Fact] public void ConvertVtqMessage_ExtractsInt64Value() [Fact] public void ConvertVtqMessage_ExtractsFloatValue() [Fact] public void ConvertVtqMessage_ExtractsDoubleValue() [Fact] public void ConvertVtqMessage_ExtractsStringValue() [Fact] public void ConvertVtqMessage_ExtractsDateTimeValue() [Fact] public void ConvertVtqMessage_HandlesNullTypedValue() [Fact] public void ConvertVtqMessage_HandlesNullMessage() [Fact] public void ConvertVtqMessage_MapsQualityCodeCorrectly() [Fact] public void ConvertVtqMessage_GoodQualityCode() [Fact] public void ConvertVtqMessage_BadQualityCode() [Fact] public void ConvertVtqMessage_UncertainQualityCode() } ``` ### 10.5 Metrics Tests **File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs` ```csharp public class ClientMetricsTests { [Fact] public void IncrementOperationCount_Increments() [Fact] public void IncrementErrorCount_Increments() [Fact] public void RecordLatency_StoresValues() [Fact] public void RollingBuffer_CapsAt1000() [Fact] public void GetSnapshot_IncludesP95AndP99() } ``` ### 10.6 Run tests ```bash ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal" ``` ## Step 11: Build Verification ```bash ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" ``` ## Completion Criteria - [ ] `ILmxProxyClient` interface updated for v2 (TypedValue parameters, onStreamError callback, CheckApiKeyAsync) - [ ] `LmxProxyClient.cs` — main file with Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey using v2 TypedValue - [ ] `LmxProxyClient.Connection.cs` — ConnectAsync, DisconnectAsync, keep-alive (30s), MarkDisconnectedAsync - [ ] `LmxProxyClient.CodeFirstSubscription.cs` — IAsyncEnumerable processing, onStreamError callback, 5s dispose timeout - [ ] `LmxProxyClient.ClientMetrics.cs` — per-op counts/errors/latency, 1000-sample buffer, p95/p99 - [ ] `LmxProxyClient.ApiKeyInfo.cs` — simple DTO - [ ] `LmxProxyClient.ISubscription.cs` — IDisposable + DisposeAsync - [ ] `ClientTlsConfiguration.cs` — all properties present - [ ] `Security/GrpcChannelFactory.cs` — TLS 1.2/1.3, cert validation, custom CA, self-signed support - [ ] No string serialization heuristics anywhere in Client code - [ ] ConvertVtqMessage extracts native TypedValue without parsing - [ ] Polly v8 ResiliencePipeline for retry (not v7 IAsyncPolicy) - [ ] All unit tests pass - [ ] Solution builds cleanly