# LmxFakeProxy: OPC UA-Backed Test Proxy for LmxProxy Protocol **Date:** 2026-03-19 **Status:** Approved ## Purpose Create a test-infrastructure gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the existing OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of the `RealLmxProxyClient` and the LmxProxy DCL adapter against real data without requiring a Windows-hosted LmxProxy deployment. ## Architecture ``` ┌─────────────────────┐ gRPC (50051) ┌──────────────────┐ OPC UA (50000) ┌─────────────────┐ │ RealLmxProxyClient │ ◄──────────────────────► │ LmxFakeProxy │ ◄───────────────────► │ OPC PLC Server │ │ (ScadaLink DCL) │ scada.ScadaService │ (infra service) │ OPC Foundation SDK │ (Docker) │ └─────────────────────┘ └──────────────────┘ └─────────────────┘ ``` - Full proto parity: implements every RPC in `scada.proto` - Configurable OPC UA endpoint prefix (`--opc-prefix`, default `ns=3;s=`) - Optional API key enforcement (`--api-key`, default accept-all) - Full session tracking with validation - Native OPC UA MonitoredItems for subscription streaming - OPC UA reconnection with bad-quality push on disconnect - Runs as Docker service (port 50051) or standalone via `dotnet run` ## Tag Address Mapping Configurable prefix prepend. Default maps LMX flat addresses to OPC PLC namespace 3: | LMX Tag | OPC UA NodeId | |---------|--------------| | `Motor.Speed` | `ns=3;s=Motor.Speed` | | `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` | | `Tank.HighLevel` | `ns=3;s=Tank.HighLevel` | Mapping: `opcNodeId = $"{prefix}{lmxTag}"` **Value conversions:** - OPC UA value → VtqMessage: `ToString()` for value, `DateTime.UtcNow.Ticks` for timestamp, StatusCode mapped to `"Good"` / `"Uncertain"` / `"Bad"` - Write value parsing (string → typed): attempt `double` → `bool` → `uint` → fall back to `string` - Quality mapping: StatusCode 0 = Good, high bit set = Bad, else Uncertain ## gRPC Service Implementation ### Connection Management - **Connect** — Validate API key (if configured), generate Guid session ID, store in `ConcurrentDictionary`. Return success + session ID. - **Disconnect** — Remove session. No-op for unknown sessions. - **GetConnectionState** — Look up session, return connection info. Return `is_connected=false` for unknown sessions. - **CheckApiKey** — Return `is_valid=true` if no key configured or key matches. ### Read Operations - **Read** — Validate session, map tag to OPC UA NodeId, read via OPC UA client, return VtqMessage. - **ReadBatch** — Same for multiple tags, sequential reads. ### Write Operations - **Write** — Validate session, parse string value to typed, write via OPC UA. - **WriteBatch** — Write each item, collect per-item results. - **WriteBatchAndWait** — Write all items, poll `flag_tag` at `poll_interval_ms` until match or timeout. ### Subscription - **Subscribe** — Validate session, create OPC UA MonitoredItems for each tag with `sampling_ms` as the OPC UA SamplingInterval. Stream VtqMessage on each data change notification. Stream stays open until client cancels. On cancellation, remove monitored items. ### Error Handling - Invalid session → `success=false`, `message="Invalid or expired session"` - OPC UA failure → `success=false` with status code in message - OPC UA disconnected → active streams get Bad quality push then close, RPCs return failure ## OPC UA Client Bridge Single shared OPC UA session to the backend server, reused across all gRPC client sessions. **`OpcUaBridge` class (behind `IOpcUaBridge` interface):** - `ConnectAsync()` — Establish OPC UA session (always `MessageSecurityMode.None`, auto-accept certs) - `ReadAsync(nodeId)` — Single node read - `WriteAsync(nodeId, value)` — Single node write - `AddMonitoredItems(nodeIds, samplingMs, callback)` — Add to shared subscription - `RemoveMonitoredItems(handles)` — Remove from shared subscription **Reconnection:** - Detect disconnection via `Session.KeepAlive` event - On disconnect: set `_connected = false`, push Bad quality VtqMessage to all active subscription streams, close streams - Background reconnect loop at 5-second fixed interval - On reconnection: re-create subscription, re-add monitored items for still-active gRPC streams - RPCs while disconnected return `success=false, "OPC UA backend unavailable"` **Single session rationale:** OPC PLC is local/lightweight, mirrors how real LmxProxy shares MXAccess, simpler lifecycle. ## API Key Authentication Accept-any by default, optional enforcement: - If `--api-key` is not set, all requests are accepted regardless of key - If `--api-key` is set, the `x-api-key` gRPC metadata header must match on every call - Validation happens in a gRPC interceptor (mirrors the real LmxProxy's `ApiKeyInterceptor`) ## Project Structure ``` infra/lmxfakeproxy/ ├── LmxFakeProxy.csproj ├── Program.cs # Host builder, CLI args / env vars, Kestrel on 50051 ├── Services/ │ └── ScadaServiceImpl.cs # gRPC service implementation ├── Bridge/ │ └── OpcUaBridge.cs # IOpcUaBridge + implementation ├── Sessions/ │ └── SessionManager.cs # ConcurrentDictionary session tracking ├── Protos/ │ └── scada.proto # Copied from DCL (generates server stubs) ├── Dockerfile # Multi-stage SDK → runtime ├── README.md └── tests/ └── LmxFakeProxy.Tests/ ├── LmxFakeProxy.Tests.csproj ├── SessionManagerTests.cs ├── TagMappingTests.cs └── ScadaServiceTests.cs ``` **NuGet dependencies:** - `Grpc.AspNetCore` — gRPC server hosting - `OPCFoundation.NetStandard.Opc.Ua.Client` — OPC UA SDK - `Microsoft.Extensions.Hosting` — generic host - Tests: `xunit`, `NSubstitute`, `Grpc.Net.Client` **CLI arguments / environment variables:** | Arg | Env Var | Default | |-----|---------|---------| | `--port` | `PORT` | `50051` | | `--opc-endpoint` | `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | | `--opc-prefix` | `OPC_PREFIX` | `ns=3;s=` | | `--api-key` | `API_KEY` | *(none — accept all)* | Env vars take precedence over CLI args. ## Docker & Infrastructure Integration **docker-compose.yml addition:** ```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 ``` **Dockerfile (multi-stage):** ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src 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"] ``` **Documentation updates:** - `docs/test_infra/test_infra.md` — Add LmxFakeProxy to services table (6th service) - `infra/README.md` — Add to quick-start table - New `docs/test_infra/test_infra_lmxfakeproxy.md` — Dedicated per-service doc - `docs/requirements/Component-DataConnectionLayer.md` — Note fake proxy availability for LmxProxy testing ## Unit Tests ### SessionManagerTests.cs - `Connect_ReturnsUniqueSessionId` - `Connect_WithValidApiKey_Succeeds` - `Connect_WithInvalidApiKey_Fails` - `Connect_WithNoKeyConfigured_AcceptsAnyKey` - `Disconnect_RemovesSession` - `Disconnect_UnknownSession_ReturnsFalse` - `ValidateSession_ValidId_ReturnsTrue` - `ValidateSession_InvalidId_ReturnsFalse` - `GetConnectionState_ReturnsCorrectInfo` - `GetConnectionState_UnknownSession_ReturnsNotConnected` ### TagMappingTests.cs - `ToOpcNodeId_PrependsPrefix` - `ToOpcNodeId_CustomPrefix` - `ToOpcNodeId_EmptyPrefix_PassesThrough` - `ConvertWriteValue_ParsesDouble` - `ConvertWriteValue_ParsesBool` - `ConvertWriteValue_ParsesUint` - `ConvertWriteValue_FallsBackToString` - `MapStatusCode_Good_ReturnsGood` - `MapStatusCode_Bad_ReturnsBad` - `MapStatusCode_Uncertain_ReturnsUncertain` - `ToVtqMessage_ConvertsCorrectly` ### ScadaServiceTests.cs (mocked IOpcUaBridge) - `Read_ValidSession_ReturnsVtq` - `Read_InvalidSession_ReturnsFailure` - `ReadBatch_ReturnsAllTags` - `Write_ValidSession_Succeeds` - `Write_InvalidSession_ReturnsFailure` - `WriteBatch_ReturnsPerItemResults` - `Subscribe_StreamsUpdatesUntilCancelled` - `Subscribe_InvalidSession_ThrowsRpcException` - `CheckApiKey_Valid_ReturnsTrue` - `CheckApiKey_Invalid_ReturnsFalse` ## Verification ```bash # Unit tests cd infra/lmxfakeproxy dotnet test tests/LmxFakeProxy.Tests/ # Docker build cd infra docker compose build lmxfakeproxy docker compose up -d lmxfakeproxy # Integration smoke test (using RealLmxProxyClient from ScadaLink) # Connect, read Motor.Speed, write Motor.Speed=42.0, read back, subscribe ```