diff --git a/docs/plans/2026-03-19-lmxfakeproxy-implementation.md b/docs/plans/2026-03-19-lmxfakeproxy-implementation.md new file mode 100644 index 0000000..5cee062 --- /dev/null +++ b/docs/plans/2026-03-19-lmxfakeproxy-implementation.md @@ -0,0 +1,1842 @@ +# 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: `test_infra.md` +- Modify: `infra/README.md` +- Create: `test_infra_lmxfakeproxy.md` +- Modify: `Component-DataConnectionLayer.md` + +**Step 1: Update 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_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 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 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_lmxfakeproxy.md) for setup. +``` + +**Step 5: Commit** + +```bash +git add test_infra.md test_infra_lmxfakeproxy.md infra/README.md 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. diff --git a/docs/plans/2026-03-19-lmxfakeproxy-implementation.md.tasks.json b/docs/plans/2026-03-19-lmxfakeproxy-implementation.md.tasks.json new file mode 100644 index 0000000..46a097a --- /dev/null +++ b/docs/plans/2026-03-19-lmxfakeproxy-implementation.md.tasks.json @@ -0,0 +1,16 @@ +{ + "planPath": "docs/plans/2026-03-19-lmxfakeproxy-implementation.md", + "tasks": [ + {"id": 1, "nativeId": "3", "subject": "Task 1: Project Scaffolding", "status": "pending"}, + {"id": 2, "nativeId": "4", "subject": "Task 2: TagMapper Utility + Tests", "status": "pending", "blockedBy": [1]}, + {"id": 3, "nativeId": "5", "subject": "Task 3: SessionManager + Tests", "status": "pending", "blockedBy": [1]}, + {"id": 4, "nativeId": "6", "subject": "Task 4: IOpcUaBridge + OpcUaBridge Implementation", "status": "pending", "blockedBy": [1]}, + {"id": 5, "nativeId": "7", "subject": "Task 5: ScadaServiceImpl + Tests", "status": "pending", "blockedBy": [2, 3, 4]}, + {"id": 6, "nativeId": "8", "subject": "Task 6: Program.cs Host Builder", "status": "pending", "blockedBy": [5]}, + {"id": 7, "nativeId": "9", "subject": "Task 7: Dockerfile + Docker Compose", "status": "pending", "blockedBy": [6]}, + {"id": 8, "nativeId": "10", "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [6]}, + {"id": 9, "nativeId": "11", "subject": "Task 9: Integration Smoke Test", "status": "pending", "blockedBy": [5, 7]}, + {"id": 10, "nativeId": "12", "subject": "Task 10: End-to-End Verification", "status": "pending", "blockedBy": [7, 8, 9]} + ], + "lastUpdated": "2026-03-19T00:00:00Z" +}