# LmxFakeProxy Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Build a .NET 10 gRPC server (`infra/lmxfakeproxy/`) that implements the `scada.ScadaService` proto and bridges to the OPC UA test server, enabling end-to-end testing of `RealLmxProxyClient`. **Architecture:** A standalone ASP.NET Core gRPC server with three core components: `SessionManager` (ConcurrentDictionary-based session tracking), `OpcUaBridge` (shared OPC UA session with reconnection), and `ScadaServiceImpl` (gRPC service mapping proto RPCs to the bridge). The server runs on port 50051, maps LMX-style tag addresses to OPC UA NodeIds via a configurable prefix, and optionally enforces API key auth via a gRPC interceptor. **Tech Stack:** .NET 10, Grpc.AspNetCore, OPCFoundation.NetStandard.Opc.Ua.Client, xunit, NSubstitute **Design doc:** `docs/plans/2026-03-19-lmxfakeproxy-design.md` --- ### Task 1: Project Scaffolding **Files:** - Create: `infra/lmxfakeproxy/LmxFakeProxy.csproj` - Create: `infra/lmxfakeproxy/Program.cs` (minimal, just enough to build) - Create: `infra/lmxfakeproxy/Protos/scada.proto` (copy from DCL) - Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj` **Step 1: Create the project directory structure** ```bash mkdir -p infra/lmxfakeproxy/Services infra/lmxfakeproxy/Bridge infra/lmxfakeproxy/Sessions infra/lmxfakeproxy/Protos mkdir -p infra/lmxfakeproxy/tests/LmxFakeProxy.Tests ``` **Step 2: Create LmxFakeProxy.csproj** ```xml net10.0 LmxFakeProxy enable enable ``` **Step 3: Copy scada.proto and change the namespace** Copy `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` to `infra/lmxfakeproxy/Protos/scada.proto`. Change the `csharp_namespace` option to: ```protobuf option csharp_namespace = "LmxFakeProxy.Grpc"; ``` Everything else in the proto stays the same — same package, same service, same messages. **Step 4: Create minimal Program.cs** ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(); var app = builder.Build(); app.MapGet("/", () => "LmxFakeProxy is running"); app.Run(); ``` **Step 5: Create test project csproj** ```xml net10.0 LmxFakeProxy.Tests enable enable false ``` **Step 6: Verify build** ```bash cd infra/lmxfakeproxy && dotnet build cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/ ``` Expected: Both build with 0 errors. The proto generates server-side stubs in `LmxFakeProxy.Grpc` namespace. **Step 7: Commit** ```bash git add infra/lmxfakeproxy/ git commit -m "feat(infra): scaffold LmxFakeProxy project with proto and test project" ``` --- ### Task 2: TagMapper Utility + Tests **Files:** - Create: `infra/lmxfakeproxy/TagMapper.cs` - Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs` **Step 1: Write the failing tests** Create `tests/LmxFakeProxy.Tests/TagMappingTests.cs`: ```csharp namespace LmxFakeProxy.Tests; public class TagMappingTests { [Fact] public void ToOpcNodeId_PrependsPrefix() { var mapper = new TagMapper("ns=3;s="); Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed")); } [Fact] public void ToOpcNodeId_CustomPrefix() { var mapper = new TagMapper("ns=2;s=MyFolder."); Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure")); } [Fact] public void ToOpcNodeId_EmptyPrefix_PassesThrough() { var mapper = new TagMapper(""); Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed")); } [Fact] public void ParseWriteValue_Double() { Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5")); Assert.IsType(TagMapper.ParseWriteValue("42.5")); } [Fact] public void ParseWriteValue_Bool() { Assert.Equal(true, TagMapper.ParseWriteValue("true")); Assert.Equal(false, TagMapper.ParseWriteValue("False")); } [Fact] public void ParseWriteValue_Uint() { // "100" parses as double first (double.TryParse succeeds for integers) // So uint only hits for values that look like uint but not double — not realistic. // Actually, double.TryParse("100") succeeds, so this returns 100.0 (double). // That's fine — OPC UA accepts double writes to UInt32 nodes. var result = TagMapper.ParseWriteValue("100"); Assert.IsType(result); } [Fact] public void ParseWriteValue_FallsBackToString() { Assert.Equal("hello", TagMapper.ParseWriteValue("hello")); Assert.IsType(TagMapper.ParseWriteValue("hello")); } [Fact] public void MapStatusCode_Good() { Assert.Equal("Good", TagMapper.MapQuality(0)); } [Fact] public void MapStatusCode_Bad() { Assert.Equal("Bad", TagMapper.MapQuality(0x80000000)); } [Fact] public void MapStatusCode_Uncertain() { Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000)); } [Fact] public void ToVtqMessage_ConvertsCorrectly() { var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0); Assert.Equal("Motor.Speed", vtq.Tag); Assert.Equal("42.5", vtq.Value); Assert.Equal("Good", vtq.Quality); Assert.True(vtq.TimestampUtcTicks > 0); } } ``` **Step 2: Run tests to verify they fail** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: FAIL — `TagMapper` class does not exist. **Step 3: Implement TagMapper** Create `infra/lmxfakeproxy/TagMapper.cs`: ```csharp using LmxFakeProxy.Grpc; namespace LmxFakeProxy; public class TagMapper { private readonly string _prefix; public TagMapper(string prefix) { _prefix = prefix; } public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}"; public static object ParseWriteValue(string value) { if (double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d)) return d; if (bool.TryParse(value, out var b)) return b; return value; } public static string MapQuality(uint statusCode) { if (statusCode == 0) return "Good"; if ((statusCode & 0x80000000) != 0) return "Bad"; return "Uncertain"; } public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode) { return new VtqMessage { Tag = tag, Value = value?.ToString() ?? string.Empty, TimestampUtcTicks = timestampUtc.Ticks, Quality = MapQuality(statusCode) }; } } ``` **Step 4: Run tests to verify they pass** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: All 11 tests PASS. **Step 5: Commit** ```bash git add infra/lmxfakeproxy/TagMapper.cs infra/lmxfakeproxy/tests/ git commit -m "feat(infra): add TagMapper with address mapping, value parsing, and quality mapping" ``` --- ### Task 3: SessionManager + Tests **Files:** - Create: `infra/lmxfakeproxy/Sessions/SessionManager.cs` - Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs` **Step 1: Write the failing tests** Create `tests/LmxFakeProxy.Tests/SessionManagerTests.cs`: ```csharp namespace LmxFakeProxy.Tests; using LmxFakeProxy.Sessions; public class SessionManagerTests { [Fact] public void Connect_ReturnsUniqueSessionId() { var mgr = new SessionManager(null); var (ok1, _, id1) = mgr.Connect("client1", ""); var (ok2, _, id2) = mgr.Connect("client2", ""); Assert.True(ok1); Assert.True(ok2); Assert.NotEqual(id1, id2); } [Fact] public void Connect_WithValidApiKey_Succeeds() { var mgr = new SessionManager("secret"); var (ok, _, _) = mgr.Connect("client1", "secret"); Assert.True(ok); } [Fact] public void Connect_WithInvalidApiKey_Fails() { var mgr = new SessionManager("secret"); var (ok, msg, id) = mgr.Connect("client1", "wrong"); Assert.False(ok); Assert.Empty(id); Assert.Contains("Invalid API key", msg); } [Fact] public void Connect_WithNoKeyConfigured_AcceptsAnyKey() { var mgr = new SessionManager(null); var (ok1, _, _) = mgr.Connect("c1", "anykey"); var (ok2, _, _) = mgr.Connect("c2", ""); Assert.True(ok1); Assert.True(ok2); } [Fact] public void Disconnect_RemovesSession() { var mgr = new SessionManager(null); var (_, _, id) = mgr.Connect("client1", ""); Assert.True(mgr.ValidateSession(id)); var ok = mgr.Disconnect(id); Assert.True(ok); Assert.False(mgr.ValidateSession(id)); } [Fact] public void Disconnect_UnknownSession_ReturnsFalse() { var mgr = new SessionManager(null); Assert.False(mgr.Disconnect("nonexistent")); } [Fact] public void ValidateSession_ValidId_ReturnsTrue() { var mgr = new SessionManager(null); var (_, _, id) = mgr.Connect("client1", ""); Assert.True(mgr.ValidateSession(id)); } [Fact] public void ValidateSession_InvalidId_ReturnsFalse() { var mgr = new SessionManager(null); Assert.False(mgr.ValidateSession("bogus")); } [Fact] public void GetConnectionState_ReturnsCorrectInfo() { var mgr = new SessionManager(null); var (_, _, id) = mgr.Connect("myClient", ""); var (found, clientId, ticks) = mgr.GetConnectionState(id); Assert.True(found); Assert.Equal("myClient", clientId); Assert.True(ticks > 0); } [Fact] public void GetConnectionState_UnknownSession_ReturnsNotConnected() { var mgr = new SessionManager(null); var (found, clientId, ticks) = mgr.GetConnectionState("unknown"); Assert.False(found); Assert.Empty(clientId); Assert.Equal(0, ticks); } [Fact] public void CheckApiKey_NoKeyConfigured_AlwaysValid() { var mgr = new SessionManager(null); Assert.True(mgr.CheckApiKey("anything")); Assert.True(mgr.CheckApiKey("")); } [Fact] public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly() { var mgr = new SessionManager("mykey"); Assert.True(mgr.CheckApiKey("mykey")); Assert.False(mgr.CheckApiKey("wrong")); Assert.False(mgr.CheckApiKey("")); } } ``` **Step 2: Run tests to verify they fail** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: FAIL — `SessionManager` does not exist. **Step 3: Implement SessionManager** Create `infra/lmxfakeproxy/Sessions/SessionManager.cs`: ```csharp using System.Collections.Concurrent; namespace LmxFakeProxy.Sessions; public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks); public class SessionManager { private readonly string? _requiredApiKey; private readonly ConcurrentDictionary _sessions = new(); public SessionManager(string? requiredApiKey) { _requiredApiKey = requiredApiKey; } public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey) { if (!CheckApiKey(apiKey)) return (false, "Invalid API key", string.Empty); var sessionId = Guid.NewGuid().ToString("N"); var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks); _sessions[sessionId] = info; return (true, "Connected", sessionId); } public bool Disconnect(string sessionId) { return _sessions.TryRemove(sessionId, out _); } public bool ValidateSession(string sessionId) { return _sessions.ContainsKey(sessionId); } public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId) { if (_sessions.TryGetValue(sessionId, out var info)) return (true, info.ClientId, info.ConnectedSinceUtcTicks); return (false, string.Empty, 0); } public bool CheckApiKey(string apiKey) { if (string.IsNullOrEmpty(_requiredApiKey)) return true; return apiKey == _requiredApiKey; } } ``` **Step 4: Run tests to verify they pass** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: All 23 tests PASS (11 TagMapping + 12 SessionManager). **Step 5: Commit** ```bash git add infra/lmxfakeproxy/Sessions/ infra/lmxfakeproxy/tests/ git commit -m "feat(infra): add SessionManager with full session tracking and API key validation" ``` --- ### Task 4: IOpcUaBridge Interface + OpcUaBridge Implementation **Files:** - Create: `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` - Create: `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` **Step 1: Create the IOpcUaBridge interface** Create `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`: ```csharp namespace LmxFakeProxy.Bridge; public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode); public interface IOpcUaBridge : IAsyncDisposable { bool IsConnected { get; } Task ConnectAsync(CancellationToken cancellationToken = default); Task ReadAsync(string nodeId, CancellationToken cancellationToken = default); Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default); /// /// Add monitored items to the shared OPC UA subscription. /// Returns a handle that can be used to remove them later. /// The callback receives (nodeId, value, timestamp, statusCode) on each data change. /// Task AddMonitoredItemsAsync( IEnumerable nodeIds, int samplingIntervalMs, Action onValueChanged, CancellationToken cancellationToken = default); Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default); /// /// Raised when the OPC UA backend becomes unreachable. /// event Action? Disconnected; /// /// Raised when the OPC UA backend reconnects after a disconnection. /// event Action? Reconnected; } ``` **Step 2: Implement OpcUaBridge** Create `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`. This mirrors `RealOpcUaClient` from the main project but adds reconnection logic and multi-client monitored item management: ```csharp using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; namespace LmxFakeProxy.Bridge; public class OpcUaBridge : IOpcUaBridge { private readonly string _endpointUrl; private readonly ILogger _logger; private ISession? _session; private Subscription? _subscription; private volatile bool _connected; private volatile bool _reconnecting; private CancellationTokenSource? _reconnectCts; // Track monitored items per handle (one handle per Subscribe call) private readonly Dictionary> _handleItems = new(); private readonly Dictionary> _handleCallbacks = new(); private readonly object _lock = new(); public OpcUaBridge(string endpointUrl, ILogger logger) { _endpointUrl = endpointUrl; _logger = logger; } public bool IsConnected => _connected; public event Action? Disconnected; public event Action? Reconnected; public async Task ConnectAsync(CancellationToken cancellationToken = default) { var appConfig = new ApplicationConfiguration { ApplicationName = "LmxFakeProxy", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { AutoAcceptUntrustedCertificates = true, ApplicationCertificate = new CertificateIdentifier(), TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") }, TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") }, RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") } }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, TransportQuotas = new TransportQuotas { OperationTimeout = 15000 } }; await appConfig.ValidateAsync(ApplicationType.Client); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; EndpointDescription? endpoint; try { #pragma warning disable CS0618 using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl)); var endpoints = discoveryClient.GetEndpoints(null); #pragma warning restore CS0618 endpoint = endpoints .Where(e => e.SecurityMode == MessageSecurityMode.None) .FirstOrDefault() ?? endpoints.FirstOrDefault(); } catch { endpoint = new EndpointDescription(_endpointUrl); } var endpointConfig = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); #pragma warning disable CS0618 var sessionFactory = new DefaultSessionFactory(); #pragma warning restore CS0618 _session = await sessionFactory.CreateAsync( appConfig, configuredEndpoint, false, "LmxFakeProxy-Session", 60000, null, null, cancellationToken); _session.KeepAlive += OnSessionKeepAlive; _subscription = new Subscription(_session.DefaultSubscription) { DisplayName = "LmxFakeProxy", PublishingEnabled = true, PublishingInterval = 500, KeepAliveCount = 10, LifetimeCount = 30, MaxNotificationsPerPublish = 1000 }; _session.AddSubscription(_subscription); await _subscription.CreateAsync(cancellationToken); _connected = true; _logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl); } public async Task ReadAsync(string nodeId, CancellationToken cancellationToken = default) { EnsureConnected(); var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }; var response = await _session!.ReadAsync( null, 0, TimestampsToReturn.Source, new ReadValueIdCollection { readValue }, cancellationToken); var result = response.Results[0]; return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code); } public async Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default) { EnsureConnected(); var writeValue = new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }; var response = await _session!.WriteAsync( null, new WriteValueCollection { writeValue }, cancellationToken); return response.Results[0].Code; } public async Task AddMonitoredItemsAsync( IEnumerable nodeIds, int samplingIntervalMs, Action onValueChanged, CancellationToken cancellationToken = default) { EnsureConnected(); var handle = Guid.NewGuid().ToString("N"); var items = new List(); foreach (var nodeId in nodeIds) { var monitoredItem = new MonitoredItem(_subscription!.DefaultItem) { DisplayName = nodeId, StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = samplingIntervalMs, QueueSize = 10, DiscardOldest = true }; monitoredItem.Notification += (item, e) => { if (e.NotificationValue is MonitoredItemNotification notification) { var val = notification.Value?.Value; var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow; var sc = notification.Value?.StatusCode.Code ?? 0; onValueChanged(nodeId, val, ts, sc); } }; items.Add(monitoredItem); _subscription!.AddItem(monitoredItem); } await _subscription!.ApplyChangesAsync(cancellationToken); lock (_lock) { _handleItems[handle] = items; _handleCallbacks[handle] = onValueChanged; } return handle; } public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default) { List? items; lock (_lock) { if (!_handleItems.Remove(handle, out items)) return; _handleCallbacks.Remove(handle); } if (_subscription != null) { foreach (var item in items) _subscription.RemoveItem(item); try { await _subscription.ApplyChangesAsync(cancellationToken); } catch { /* best-effort during cleanup */ } } } private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e) { if (ServiceResult.IsBad(e.Status)) { if (!_connected) return; _connected = false; _logger.LogWarning("OPC UA backend connection lost"); Disconnected?.Invoke(); StartReconnectLoop(); } } private void StartReconnectLoop() { if (_reconnecting) return; _reconnecting = true; _reconnectCts = new CancellationTokenSource(); _ = Task.Run(async () => { while (!_reconnectCts.Token.IsCancellationRequested) { await Task.Delay(5000, _reconnectCts.Token); try { _logger.LogInformation("Attempting OPC UA reconnection..."); // Clean up old session if (_session != null) { _session.KeepAlive -= OnSessionKeepAlive; try { await _session.CloseAsync(); } catch { } _session = null; _subscription = null; } await ConnectAsync(_reconnectCts.Token); // Re-add monitored items for active handles lock (_lock) { foreach (var (handle, callback) in _handleCallbacks) { if (_handleItems.TryGetValue(handle, out var oldItems)) { var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList(); var newItems = new List(); foreach (var nodeId in nodeIds) { var monitoredItem = new MonitoredItem(_subscription!.DefaultItem) { DisplayName = nodeId, StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = oldItems[0].SamplingInterval, QueueSize = 10, DiscardOldest = true }; var capturedNodeId = nodeId; var capturedCallback = callback; monitoredItem.Notification += (item, ev) => { if (ev.NotificationValue is MonitoredItemNotification notification) { var val = notification.Value?.Value; var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow; var sc = notification.Value?.StatusCode.Code ?? 0; capturedCallback(capturedNodeId, val, ts, sc); } }; newItems.Add(monitoredItem); _subscription!.AddItem(monitoredItem); } _handleItems[handle] = newItems; } } } if (_subscription != null) await _subscription.ApplyChangesAsync(); _reconnecting = false; _logger.LogInformation("OPC UA reconnection successful"); Reconnected?.Invoke(); return; } catch (Exception ex) { _logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s"); } } }, _reconnectCts.Token); } private void EnsureConnected() { if (!_connected || _session == null) throw new InvalidOperationException("OPC UA backend unavailable"); } public async ValueTask DisposeAsync() { _reconnectCts?.Cancel(); _reconnectCts?.Dispose(); if (_subscription != null) { try { await _subscription.DeleteAsync(true); } catch { } _subscription = null; } if (_session != null) { _session.KeepAlive -= OnSessionKeepAlive; try { await _session.CloseAsync(); } catch { } _session = null; } _connected = false; } } ``` **Step 3: Verify build** ```bash cd infra/lmxfakeproxy && dotnet build ``` Expected: 0 errors. **Step 4: Commit** ```bash git add infra/lmxfakeproxy/Bridge/ git commit -m "feat(infra): add IOpcUaBridge interface and OpcUaBridge with OPC UA reconnection" ``` --- ### Task 5: ScadaServiceImpl (gRPC Service) + Tests **Files:** - Create: `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` - Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs` **Step 1: Write the failing tests** Create `tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`. These tests mock `IOpcUaBridge` and use real `SessionManager` + `TagMapper`: ```csharp using Grpc.Core; using NSubstitute; using LmxFakeProxy.Bridge; using LmxFakeProxy.Grpc; using LmxFakeProxy.Sessions; using LmxFakeProxy.Services; namespace LmxFakeProxy.Tests; public class ScadaServiceTests { private readonly IOpcUaBridge _mockBridge; private readonly SessionManager _sessionMgr; private readonly TagMapper _tagMapper; private readonly ScadaServiceImpl _service; public ScadaServiceTests() { _mockBridge = Substitute.For(); _mockBridge.IsConnected.Returns(true); _sessionMgr = new SessionManager(null); _tagMapper = new TagMapper("ns=3;s="); _service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper); } private string ConnectClient(string clientId = "test-client") { var (_, _, sessionId) = _sessionMgr.Connect(clientId, ""); return sessionId; } private static ServerCallContext MockContext() { return new TestServerCallContext(); } // --- Connection --- [Fact] public async Task Connect_ReturnsSessionId() { var resp = await _service.Connect( new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext()); Assert.True(resp.Success); Assert.NotEmpty(resp.SessionId); } // --- Read --- [Fact] public async Task Read_ValidSession_ReturnsVtq() { var sid = ConnectClient(); _mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any()) .Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0)); var resp = await _service.Read( new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext()); Assert.True(resp.Success); Assert.Equal("42.5", resp.Vtq.Value); Assert.Equal("Good", resp.Vtq.Quality); } [Fact] public async Task Read_InvalidSession_ReturnsFailure() { var resp = await _service.Read( new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext()); Assert.False(resp.Success); Assert.Contains("Invalid", resp.Message); } [Fact] public async Task ReadBatch_ReturnsAllTags() { var sid = ConnectClient(); _mockBridge.ReadAsync(Arg.Any(), Arg.Any()) .Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0)); var req = new ReadBatchRequest { SessionId = sid }; req.Tags.AddRange(["Motor.Speed", "Pump.FlowRate"]); var resp = await _service.ReadBatch(req, MockContext()); Assert.True(resp.Success); Assert.Equal(2, resp.Vtqs.Count); } // --- Write --- [Fact] public async Task Write_ValidSession_Succeeds() { var sid = ConnectClient(); _mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any(), Arg.Any()) .Returns(0u); var resp = await _service.Write( new WriteRequest { SessionId = sid, Tag = "Motor.Speed", Value = "42.5" }, MockContext()); Assert.True(resp.Success); } [Fact] public async Task Write_InvalidSession_ReturnsFailure() { var resp = await _service.Write( new WriteRequest { SessionId = "bogus", Tag = "Motor.Speed", Value = "42.5" }, MockContext()); Assert.False(resp.Success); } [Fact] public async Task WriteBatch_ReturnsPerItemResults() { var sid = ConnectClient(); _mockBridge.WriteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(0u); var req = new WriteBatchRequest { SessionId = sid }; req.Items.Add(new WriteItem { Tag = "Motor.Speed", Value = "42.5" }); req.Items.Add(new WriteItem { Tag = "Pump.FlowRate", Value = "10.0" }); var resp = await _service.WriteBatch(req, MockContext()); Assert.True(resp.Success); Assert.Equal(2, resp.Results.Count); Assert.All(resp.Results, r => Assert.True(r.Success)); } // --- CheckApiKey --- [Fact] public async Task CheckApiKey_Valid_ReturnsTrue() { var resp = await _service.CheckApiKey( new CheckApiKeyRequest { ApiKey = "anything" }, MockContext()); Assert.True(resp.IsValid); } [Fact] public async Task CheckApiKey_Invalid_ReturnsFalse() { var mgr = new SessionManager("secret"); var svc = new ScadaServiceImpl(mgr, _mockBridge, _tagMapper); var resp = await svc.CheckApiKey( new CheckApiKeyRequest { ApiKey = "wrong" }, MockContext()); Assert.False(resp.IsValid); } } /// /// Minimal ServerCallContext implementation for unit testing gRPC services. /// internal class TestServerCallContext : ServerCallContext { protected override string MethodCore => "test"; protected override string HostCore => "localhost"; protected override string PeerCore => "test-peer"; protected override DateTime DeadlineCore => DateTime.MaxValue; protected override Metadata RequestHeadersCore => new(); protected override CancellationToken CancellationTokenCore => CancellationToken.None; protected override Metadata ResponseTrailersCore => new(); protected override Status StatusCore { get; set; } protected override WriteOptions? WriteOptionsCore { get; set; } protected override AuthContext AuthContextCore => new("test", new Dictionary>()); protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotImplementedException(); protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } ``` **Step 2: Run tests to verify they fail** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: FAIL — `ScadaServiceImpl` does not exist. **Step 3: Implement ScadaServiceImpl** Create `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs`: ```csharp using Grpc.Core; using LmxFakeProxy.Bridge; using LmxFakeProxy.Grpc; using LmxFakeProxy.Sessions; namespace LmxFakeProxy.Services; public class ScadaServiceImpl : ScadaService.ScadaServiceBase { private readonly SessionManager _sessions; private readonly IOpcUaBridge _bridge; private readonly TagMapper _tagMapper; public ScadaServiceImpl(SessionManager sessions, IOpcUaBridge bridge, TagMapper tagMapper) { _sessions = sessions; _bridge = bridge; _tagMapper = tagMapper; } public override Task Connect(ConnectRequest request, ServerCallContext context) { var (success, message, sessionId) = _sessions.Connect(request.ClientId, request.ApiKey); return Task.FromResult(new ConnectResponse { Success = success, Message = message, SessionId = sessionId }); } public override Task Disconnect(DisconnectRequest request, ServerCallContext context) { var ok = _sessions.Disconnect(request.SessionId); return Task.FromResult(new DisconnectResponse { Success = ok, Message = ok ? "Disconnected" : "Session not found" }); } public override Task GetConnectionState( GetConnectionStateRequest request, ServerCallContext context) { var (found, clientId, ticks) = _sessions.GetConnectionState(request.SessionId); return Task.FromResult(new GetConnectionStateResponse { IsConnected = found, ClientId = clientId, ConnectedSinceUtcTicks = ticks }); } public override Task CheckApiKey(CheckApiKeyRequest request, ServerCallContext context) { var valid = _sessions.CheckApiKey(request.ApiKey); return Task.FromResult(new CheckApiKeyResponse { IsValid = valid, Message = valid ? "Valid" : "Invalid API key" }); } public override async Task Read(ReadRequest request, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) return new ReadResponse { Success = false, Message = "Invalid or expired session" }; try { var nodeId = _tagMapper.ToOpcNodeId(request.Tag); var result = await _bridge.ReadAsync(nodeId, context.CancellationToken); return new ReadResponse { Success = true, Vtq = TagMapper.ToVtqMessage(request.Tag, result.Value, result.SourceTimestamp, result.StatusCode) }; } catch (Exception ex) { return new ReadResponse { Success = false, Message = ex.Message }; } } public override async Task ReadBatch(ReadBatchRequest request, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) return new ReadBatchResponse { Success = false, Message = "Invalid or expired session" }; var response = new ReadBatchResponse { Success = true }; foreach (var tag in request.Tags) { try { var nodeId = _tagMapper.ToOpcNodeId(tag); var result = await _bridge.ReadAsync(nodeId, context.CancellationToken); response.Vtqs.Add(TagMapper.ToVtqMessage(tag, result.Value, result.SourceTimestamp, result.StatusCode)); } catch (Exception ex) { response.Vtqs.Add(new VtqMessage { Tag = tag, Value = "", Quality = "Bad", TimestampUtcTicks = DateTime.UtcNow.Ticks }); response.Message = ex.Message; } } return response; } public override async Task Write(WriteRequest request, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) return new WriteResponse { Success = false, Message = "Invalid or expired session" }; try { var nodeId = _tagMapper.ToOpcNodeId(request.Tag); var value = TagMapper.ParseWriteValue(request.Value); var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); return statusCode == 0 ? new WriteResponse { Success = true } : new WriteResponse { Success = false, Message = $"OPC UA write failed: 0x{statusCode:X8}" }; } catch (Exception ex) { return new WriteResponse { Success = false, Message = ex.Message }; } } public override async Task WriteBatch(WriteBatchRequest request, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) return new WriteBatchResponse { Success = false, Message = "Invalid or expired session" }; var response = new WriteBatchResponse { Success = true }; foreach (var item in request.Items) { try { var nodeId = _tagMapper.ToOpcNodeId(item.Tag); var value = TagMapper.ParseWriteValue(item.Value); var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); response.Results.Add(new Grpc.WriteResult { Tag = item.Tag, Success = statusCode == 0, Message = statusCode == 0 ? "" : $"0x{statusCode:X8}" }); if (statusCode != 0) response.Success = false; } catch (Exception ex) { response.Results.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message }); response.Success = false; } } return response; } public override async Task WriteBatchAndWait( WriteBatchAndWaitRequest request, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) return new WriteBatchAndWaitResponse { Success = false, Message = "Invalid or expired session" }; var startTime = DateTime.UtcNow; // Write all items var writeResults = new List(); var allWritesOk = true; foreach (var item in request.Items) { try { var nodeId = _tagMapper.ToOpcNodeId(item.Tag); var value = TagMapper.ParseWriteValue(item.Value); var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = statusCode == 0, Message = statusCode == 0 ? "" : $"0x{statusCode:X8}" }); if (statusCode != 0) allWritesOk = false; } catch (Exception ex) { writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message }); allWritesOk = false; } } if (!allWritesOk) { var resp = new WriteBatchAndWaitResponse { Success = false, Message = "Write failed" }; resp.WriteResults.AddRange(writeResults); return resp; } // Poll for flag value var flagNodeId = _tagMapper.ToOpcNodeId(request.FlagTag); var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000; var pollMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; var deadline = startTime.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { context.CancellationToken.ThrowIfCancellationRequested(); try { var readResult = await _bridge.ReadAsync(flagNodeId, context.CancellationToken); if (readResult.Value?.ToString() == request.FlagValue) { var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; var resp = new WriteBatchAndWaitResponse { Success = true, FlagReached = true, ElapsedMs = elapsed }; resp.WriteResults.AddRange(writeResults); return resp; } } catch { /* read failure during poll — keep trying */ } await Task.Delay(pollMs, context.CancellationToken); } var finalResp = new WriteBatchAndWaitResponse { Success = true, FlagReached = false, ElapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds, Message = "Timeout waiting for flag value" }; finalResp.WriteResults.AddRange(writeResults); return finalResp; } public override async Task Subscribe( SubscribeRequest request, IServerStreamWriter responseStream, ServerCallContext context) { if (!_sessions.ValidateSession(request.SessionId)) throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session")); var nodeIds = request.Tags.Select(t => _tagMapper.ToOpcNodeId(t)).ToList(); var tagByNodeId = request.Tags.Zip(nodeIds).ToDictionary(p => p.Second, p => p.First); var handle = await _bridge.AddMonitoredItemsAsync( nodeIds, request.SamplingMs, (nodeId, value, timestamp, statusCode) => { if (tagByNodeId.TryGetValue(nodeId, out var tag)) { var vtq = TagMapper.ToVtqMessage(tag, value, timestamp, statusCode); try { responseStream.WriteAsync(vtq).Wait(); } catch { /* stream closed */ } } }, context.CancellationToken); try { // Keep the stream open until the client cancels await Task.Delay(Timeout.Infinite, context.CancellationToken); } catch (OperationCanceledException) { } finally { await _bridge.RemoveMonitoredItemsAsync(handle); } } } ``` **Step 4: Run tests to verify they pass** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n ``` Expected: All tests PASS (11 TagMapping + 12 SessionManager + 9 ScadaService = 32). **Step 5: Commit** ```bash git add infra/lmxfakeproxy/Services/ infra/lmxfakeproxy/tests/ git commit -m "feat(infra): add ScadaServiceImpl with full proto parity for all RPCs" ``` --- ### Task 6: Program.cs — Host Builder with CLI Args **Files:** - Modify: `infra/lmxfakeproxy/Program.cs` **Step 1: Replace the minimal Program.cs with the full host builder** ```csharp using LmxFakeProxy; using LmxFakeProxy.Bridge; using LmxFakeProxy.Services; using LmxFakeProxy.Sessions; var builder = WebApplication.CreateBuilder(args); // Configuration: env vars take precedence over CLI args var port = Environment.GetEnvironmentVariable("PORT") ?? GetArg(args, "--port") ?? "50051"; var opcEndpoint = Environment.GetEnvironmentVariable("OPC_ENDPOINT") ?? GetArg(args, "--opc-endpoint") ?? "opc.tcp://localhost:50000"; var opcPrefix = Environment.GetEnvironmentVariable("OPC_PREFIX") ?? GetArg(args, "--opc-prefix") ?? "ns=3;s="; var apiKey = Environment.GetEnvironmentVariable("API_KEY") ?? GetArg(args, "--api-key"); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(int.Parse(port), listenOptions => { listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; }); }); // Register services var sessionManager = new SessionManager(apiKey); var tagMapper = new TagMapper(opcPrefix); var opcUaBridge = new OpcUaBridge(opcEndpoint, builder.Services.BuildServiceProvider().GetRequiredService>()); builder.Services.AddSingleton(sessionManager); builder.Services.AddSingleton(tagMapper); builder.Services.AddSingleton(opcUaBridge); builder.Services.AddGrpc(); var app = builder.Build(); app.MapGrpcService(); app.MapGet("/", () => "LmxFakeProxy is running"); // Connect to OPC UA backend var logger = app.Services.GetRequiredService>(); logger.LogInformation("LmxFakeProxy starting on port {Port}", port); logger.LogInformation("OPC UA endpoint: {Endpoint}, prefix: {Prefix}", opcEndpoint, opcPrefix); logger.LogInformation("API key enforcement: {Enforced}", apiKey != null ? "enabled" : "disabled (accept all)"); try { await opcUaBridge.ConnectAsync(); logger.LogInformation("OPC UA bridge connected"); } catch (Exception ex) { logger.LogWarning(ex, "Initial OPC UA connection failed — will retry in background"); } await app.RunAsync(); static string? GetArg(string[] args, string name) { var idx = Array.IndexOf(args, name); return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null; } ``` **Step 2: Verify build and basic startup** ```bash cd infra/lmxfakeproxy && dotnet build ``` Expected: 0 errors. **Step 3: Commit** ```bash git add infra/lmxfakeproxy/Program.cs git commit -m "feat(infra): wire up Program.cs with CLI args, env vars, and OPC UA bridge startup" ``` --- ### Task 7: Dockerfile + Docker Compose Integration **Files:** - Create: `infra/lmxfakeproxy/Dockerfile` - Modify: `infra/docker-compose.yml` **Step 1: Create the Dockerfile** Create `infra/lmxfakeproxy/Dockerfile`: ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY LmxFakeProxy.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app . EXPOSE 50051 ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"] ``` Note: Do NOT copy the `tests/` directory into the build — the `.dockerignore` or the `COPY` pattern handles this naturally since only the project root files and source folders are needed. **Step 2: Create `.dockerignore`** Create `infra/lmxfakeproxy/.dockerignore`: ``` tests/ bin/ obj/ ``` **Step 3: Add the service to docker-compose.yml** Add the following service block before the `volumes:` section in `infra/docker-compose.yml`: ```yaml lmxfakeproxy: build: ./lmxfakeproxy container_name: scadalink-lmxfakeproxy ports: - "50051:50051" environment: OPC_ENDPOINT: "opc.tcp://opcua:50000" OPC_PREFIX: "ns=3;s=" depends_on: - opcua networks: - scadalink-net restart: unless-stopped ``` **Step 4: Verify Docker build** ```bash cd infra && docker compose build lmxfakeproxy ``` Expected: Build succeeds. **Step 5: Commit** ```bash git add infra/lmxfakeproxy/Dockerfile infra/lmxfakeproxy/.dockerignore infra/docker-compose.yml git commit -m "feat(infra): add LmxFakeProxy Dockerfile and docker-compose service" ``` --- ### Task 8: Documentation Updates **Files:** - Modify: `docs/test_infra/test_infra.md` - Modify: `infra/README.md` - Create: `docs/test_infra/test_infra_lmxfakeproxy.md` - Modify: `docs/requirements/Component-DataConnectionLayer.md` **Step 1: Update docs/test_infra/test_infra.md** Add a row to the Services table: ``` | LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | ``` Add a bullet to the per-service documentation list: ``` - [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge) ``` Update the Files section to add: ``` lmxfakeproxy/ # .NET gRPC proxy bridging LmxProxy protocol to OPC UA ``` **Step 2: Update infra/README.md** Add a row to the quick-start table: ``` | LmxFakeProxy (.NET gRPC) | 50051 (gRPC) | LmxProxy-compatible server bridging to OPC UA test server | ``` **Step 3: Create docs/test_infra/test_infra_lmxfakeproxy.md** ```markdown # Test Infrastructure: LmxFakeProxy ## Overview LmxFakeProxy is a .NET gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of `RealLmxProxyClient` and the LmxProxy DCL adapter. ## Image & Ports - **Image**: Custom build (`infra/lmxfakeproxy/Dockerfile`) - **gRPC endpoint**: `localhost:50051` ## Configuration | Environment Variable | Default | Description | |---------------------|---------|-------------| | `PORT` | `50051` | gRPC listen port | | `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | Backend OPC UA server | | `OPC_PREFIX` | `ns=3;s=` | Prefix prepended to LMX tags to form OPC UA NodeIds | | `API_KEY` | *(none)* | If set, enforces API key on all gRPC calls | ## Tag Address Mapping LMX-style flat addresses are mapped to OPC UA NodeIds by prepending the configured prefix: | LMX Tag | OPC UA NodeId | |---------|--------------| | `Motor.Speed` | `ns=3;s=Motor.Speed` | | `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` | | `Tank.Level` | `ns=3;s=Tank.Level` | ## Supported RPCs Full parity with the `scada.ScadaService` proto: - **Connect / Disconnect / GetConnectionState** — Session management - **Read / ReadBatch** — Read tag values via OPC UA - **Write / WriteBatch / WriteBatchAndWait** — Write values via OPC UA - **Subscribe** — Server-streaming subscriptions via OPC UA MonitoredItems - **CheckApiKey** — API key validation ## Verification 1. Ensure the OPC UA test server is running: ```bash docker ps --filter name=scadalink-opcua ``` 2. Start the fake proxy: ```bash docker compose up -d lmxfakeproxy ``` 3. Check logs: ```bash docker logs scadalink-lmxfakeproxy ``` 4. Test with the ScadaLink CLI or a gRPC client. ## Running Standalone (without Docker) ```bash cd infra/lmxfakeproxy dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s=" ``` With API key enforcement: ```bash dotnet run -- --api-key my-secret-key ``` ## Relevance to ScadaLink Components - **Data Connection Layer** — Test `RealLmxProxyClient` and `LmxProxyDataConnection` against real OPC UA data - **Site Runtime** — Deploy instances with LmxProxy data connections pointing at this server - **Integration Tests** — End-to-end tests of the LmxProxy protocol path ``` **Step 4: Update docs/requirements/Component-DataConnectionLayer.md** Add a note in the LmxProxy section (after the "Proto Source" paragraph, before "## Subscription Management"): ```markdown **Test Infrastructure**: The `infra/lmxfakeproxy/` project provides a fake LmxProxy server that bridges to the OPC UA test server. It implements the full `scada.ScadaService` proto, enabling end-to-end testing of `RealLmxProxyClient` without a Windows LmxProxy deployment. See [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) for setup. ``` **Step 5: Commit** ```bash git add docs/test_infra/test_infra.md docs/test_infra/test_infra_lmxfakeproxy.md infra/README.md docs/requirements/Component-DataConnectionLayer.md git commit -m "docs: add LmxFakeProxy to test infrastructure documentation" ``` --- ### Task 9: Integration Smoke Test with RealLmxProxyClient **Files:** - Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs` This test is an end-to-end smoke test that verifies the fake proxy works with the actual `RealLmxProxyClient`. It requires the OPC UA test server and LmxFakeProxy to both be running (Docker or standalone). Mark it with a `[Trait]` so it can be skipped in CI. **Step 1: Add the DCL project reference to the test csproj** Add to `tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj`: ```xml ``` **Step 2: Write the integration test** Create `tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs`: ```csharp using ScadaLink.DataConnectionLayer.Adapters; namespace LmxFakeProxy.Tests; /// /// End-to-end smoke test that connects RealLmxProxyClient to LmxFakeProxy. /// Requires both OPC UA test server and LmxFakeProxy to be running. /// Run manually: dotnet test --filter "Category=Integration" /// [Trait("Category", "Integration")] public class IntegrationSmokeTest { private const string Host = "localhost"; private const int Port = 50051; [Fact] public async Task ConnectReadWriteSubscribe_EndToEnd() { var client = new RealLmxProxyClient(Host, Port, apiKey: null); try { // Connect await client.ConnectAsync(); Assert.True(client.IsConnected); // Read initial value var vtq = await client.ReadAsync("Motor.Speed"); Assert.Equal(LmxQuality.Good, vtq.Quality); // Write a value await client.WriteAsync("Motor.Speed", 42.5); // Read back var vtq2 = await client.ReadAsync("Motor.Speed"); Assert.Equal(42.5, (double)vtq2.Value!); // ReadBatch var batch = await client.ReadBatchAsync(["Motor.Speed", "Pump.FlowRate"]); Assert.Equal(2, batch.Count); // Subscribe briefly LmxVtq? lastUpdate = null; var sub = await client.SubscribeAsync( ["Motor.Speed"], (tag, v) => lastUpdate = v); // Write to trigger subscription update await client.WriteAsync("Motor.Speed", 99.0); await Task.Delay(2000); // Wait for subscription delivery await sub.DisposeAsync(); // Verify we got at least one subscription update Assert.NotNull(lastUpdate); // Disconnect await client.DisconnectAsync(); } finally { await client.DisposeAsync(); } } } ``` **Step 3: Verify build (do NOT run yet — requires running infra)** ```bash cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/ ``` Expected: 0 errors. **Step 4: Run unit tests only (exclude integration)** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n ``` Expected: All unit tests pass. Integration test is skipped. **Step 5: Commit** ```bash git add infra/lmxfakeproxy/tests/ git commit -m "test(infra): add integration smoke test for RealLmxProxyClient against LmxFakeProxy" ``` --- ### Task 10: End-to-End Verification **No new files — verification only.** **Step 1: Start the infrastructure** ```bash cd infra && docker compose up -d ``` Wait for OPC UA server to be ready: ```bash docker logs scadalink-opcua 2>&1 | tail -5 ``` **Step 2: Verify LmxFakeProxy logs** ```bash docker logs scadalink-lmxfakeproxy ``` Expected: "OPC UA bridge connected" message. **Step 3: Run the integration smoke test** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category=Integration" -v n ``` Expected: Integration test passes — connect, read, write, read-back, subscribe all work. **Step 4: Run all unit tests to confirm no regressions** ```bash cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n ``` Expected: All unit tests pass. **Step 5: Final commit (if any fixes were needed)** ```bash git add -A && git commit -m "fix(infra): address issues found during end-to-end verification" ``` Only commit if changes were needed. If everything passed cleanly, skip this step.