Defines a gRPC server implementing the scada.ScadaService proto that bridges to the existing OPC UA test server. Enables end-to-end testing of RealLmxProxyClient without a Windows LmxProxy deployment.
9.2 KiB
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, defaultns=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.Ticksfor timestamp, StatusCode mapped to"Good"/"Uncertain"/"Bad" - Write value parsing (string → typed): attempt
double→bool→uint→ fall back tostring - 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<string, SessionInfo>. Return success + session ID. - Disconnect — Remove session. No-op for unknown sessions.
- GetConnectionState — Look up session, return connection info. Return
is_connected=falsefor unknown sessions. - CheckApiKey — Return
is_valid=trueif 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_tagatpoll_interval_msuntil match or timeout.
Subscription
- Subscribe — Validate session, create OPC UA MonitoredItems for each tag with
sampling_msas 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=falsewith 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 (alwaysMessageSecurityMode.None, auto-accept certs)ReadAsync(nodeId)— Single node readWriteAsync(nodeId, value)— Single node writeAddMonitoredItems(nodeIds, samplingMs, callback)— Add to shared subscriptionRemoveMonitoredItems(handles)— Remove from shared subscription
Reconnection:
- Detect disconnection via
Session.KeepAliveevent - 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-keyis not set, all requests are accepted regardless of key - If
--api-keyis set, thex-api-keygRPC 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 hostingOPCFoundation.NetStandard.Opc.Ua.Client— OPC UA SDKMicrosoft.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:
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):
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:
test_infra.md— Add LmxFakeProxy to services table (6th service)infra/README.md— Add to quick-start table- New
test_infra_lmxfakeproxy.md— Dedicated per-service doc Component-DataConnectionLayer.md— Note fake proxy availability for LmxProxy testing
Unit Tests
SessionManagerTests.cs
Connect_ReturnsUniqueSessionIdConnect_WithValidApiKey_SucceedsConnect_WithInvalidApiKey_FailsConnect_WithNoKeyConfigured_AcceptsAnyKeyDisconnect_RemovesSessionDisconnect_UnknownSession_ReturnsFalseValidateSession_ValidId_ReturnsTrueValidateSession_InvalidId_ReturnsFalseGetConnectionState_ReturnsCorrectInfoGetConnectionState_UnknownSession_ReturnsNotConnected
TagMappingTests.cs
ToOpcNodeId_PrependsPrefixToOpcNodeId_CustomPrefixToOpcNodeId_EmptyPrefix_PassesThroughConvertWriteValue_ParsesDoubleConvertWriteValue_ParsesBoolConvertWriteValue_ParsesUintConvertWriteValue_FallsBackToStringMapStatusCode_Good_ReturnsGoodMapStatusCode_Bad_ReturnsBadMapStatusCode_Uncertain_ReturnsUncertainToVtqMessage_ConvertsCorrectly
ScadaServiceTests.cs (mocked IOpcUaBridge)
Read_ValidSession_ReturnsVtqRead_InvalidSession_ReturnsFailureReadBatch_ReturnsAllTagsWrite_ValidSession_SucceedsWrite_InvalidSession_ReturnsFailureWriteBatch_ReturnsPerItemResultsSubscribe_StreamsUpdatesUntilCancelledSubscribe_InvalidSession_ThrowsRpcExceptionCheckApiKey_Valid_ReturnsTrueCheckApiKey_Invalid_ReturnsFalse
Verification
# 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