Files
scadalink-design/docs/plans/2026-03-19-lmxfakeproxy-design.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

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, 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 doublebooluint → 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<string, SessionInfo>. 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:

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:

  • 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

# 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