Files
scadalink-design/docs/plans/2026-03-19-lmxfakeproxy-implementation.md
Joseph Doherty d91aa83665 refactor(docs): move requirements and test infra docs into docs/ subdirectories
Organize documentation by moving requirements (HighLevelReqs, Component-*,
lmxproxy_protocol) to docs/requirements/ and test infrastructure docs to
docs/test_infra/. Updates all cross-references in README, CLAUDE.md,
infra/README, component docs, and 23 plan files.
2026-03-21 01:11:35 -04:00

57 KiB

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

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

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>LmxFakeProxy</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <Protobuf Include="Protos/scada.proto" GrpcServices="Server" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
    <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126" />
  </ItemGroup>
</Project>

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:

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

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

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>LmxFakeProxy.Tests</RootNamespace>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
    <PackageReference Include="NSubstitute" Version="5.3.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../../LmxFakeProxy.csproj" />
  </ItemGroup>
</Project>

Step 6: Verify build

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

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:

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<double>(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<double>(result);
    }

    [Fact]
    public void ParseWriteValue_FallsBackToString()
    {
        Assert.Equal("hello", TagMapper.ParseWriteValue("hello"));
        Assert.IsType<string>(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

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:

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

cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n

Expected: All 11 tests PASS.

Step 5: Commit

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:

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

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:

using System.Collections.Concurrent;

namespace LmxFakeProxy.Sessions;

public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks);

public class SessionManager
{
    private readonly string? _requiredApiKey;
    private readonly ConcurrentDictionary<string, SessionInfo> _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

cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n

Expected: All 23 tests PASS (11 TagMapping + 12 SessionManager).

Step 5: Commit

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:

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<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default);

    Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default);

    /// <summary>
    /// 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.
    /// </summary>
    Task<string> AddMonitoredItemsAsync(
        IEnumerable<string> nodeIds,
        int samplingIntervalMs,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default);

    Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default);

    /// <summary>
    /// Raised when the OPC UA backend becomes unreachable.
    /// </summary>
    event Action? Disconnected;

    /// <summary>
    /// Raised when the OPC UA backend reconnects after a disconnection.
    /// </summary>
    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:

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<OpcUaBridge> _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<string, List<MonitoredItem>> _handleItems = new();
    private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _handleCallbacks = new();
    private readonly object _lock = new();

    public OpcUaBridge(string endpointUrl, ILogger<OpcUaBridge> 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<OpcUaReadResult> 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<uint> 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<string> AddMonitoredItemsAsync(
        IEnumerable<string> nodeIds,
        int samplingIntervalMs,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        var handle = Guid.NewGuid().ToString("N");
        var items = new List<MonitoredItem>();

        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<MonitoredItem>? 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<MonitoredItem>();

                                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

cd infra/lmxfakeproxy && dotnet build

Expected: 0 errors.

Step 4: Commit

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:

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<IOpcUaBridge>();
        _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<CancellationToken>())
            .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<string>(), Arg.Any<CancellationToken>())
            .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<object?>(), Arg.Any<CancellationToken>())
            .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<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
            .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);
    }
}

/// <summary>
/// Minimal ServerCallContext implementation for unit testing gRPC services.
/// </summary>
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<string, List<AuthProperty>>());

    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

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:

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<ConnectResponse> 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<DisconnectResponse> 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<GetConnectionStateResponse> 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<CheckApiKeyResponse> 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<ReadResponse> 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<ReadBatchResponse> 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<WriteResponse> 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<WriteBatchResponse> 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<WriteBatchAndWaitResponse> 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<Grpc.WriteResult>();
        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<VtqMessage> 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

cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n

Expected: All tests PASS (11 TagMapping + 12 SessionManager + 9 ScadaService = 32).

Step 5: Commit

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

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<ILogger<OpcUaBridge>>());

builder.Services.AddSingleton(sessionManager);
builder.Services.AddSingleton(tagMapper);
builder.Services.AddSingleton<IOpcUaBridge>(opcUaBridge);
builder.Services.AddGrpc();

var app = builder.Build();

app.MapGrpcService<ScadaServiceImpl>();
app.MapGet("/", () => "LmxFakeProxy is running");

// Connect to OPC UA backend
var logger = app.Services.GetRequiredService<ILogger<Program>>();
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

cd infra/lmxfakeproxy && dotnet build

Expected: 0 errors.

Step 3: Commit

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:

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:

  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

cd infra && docker compose build lmxfakeproxy

Expected: Build succeeds.

Step 5: Commit

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

# 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
  1. Start the fake proxy:
docker compose up -d lmxfakeproxy
  1. Check logs:
docker logs scadalink-lmxfakeproxy
  1. Test with the ScadaLink CLI or a gRPC client.

Running Standalone (without Docker)

cd infra/lmxfakeproxy
dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s="

With API key enforcement:

dotnet run -- --api-key my-secret-key
  • 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

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:

<ProjectReference Include="../../../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />

Step 2: Write the integration test

Create tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs:

using ScadaLink.DataConnectionLayer.Adapters;

namespace LmxFakeProxy.Tests;

/// <summary>
/// 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"
/// </summary>
[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)

cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/

Expected: 0 errors.

Step 4: Run unit tests only (exclude integration)

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

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

cd infra && docker compose up -d

Wait for OPC UA server to be ready:

docker logs scadalink-opcua 2>&1 | tail -5

Step 2: Verify LmxFakeProxy logs

docker logs scadalink-lmxfakeproxy

Expected: "OPC UA bridge connected" message.

Step 3: Run the integration smoke test

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

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)

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.