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