docs: add LmxFakeProxy design — OPC UA-backed test proxy for LmxProxy protocol

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.
This commit is contained in:
Joseph Doherty
2026-03-19 11:08:47 -04:00
parent e837eae2cc
commit e19a568b9b

View File

@@ -0,0 +1,228 @@
# 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<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:**
```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:**
- `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_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
```