From e19a568b9bad60a28df802fb586a190f404aaa1a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 19 Mar 2026 11:08:47 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20add=20LmxFakeProxy=20design=20=E2=80=94?= =?UTF-8?q?=20OPC=20UA-backed=20test=20proxy=20for=20LmxProxy=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/2026-03-19-lmxfakeproxy-design.md | 228 +++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/plans/2026-03-19-lmxfakeproxy-design.md diff --git a/docs/plans/2026-03-19-lmxfakeproxy-design.md b/docs/plans/2026-03-19-lmxfakeproxy-design.md new file mode 100644 index 0000000..bb97c3e --- /dev/null +++ b/docs/plans/2026-03-19-lmxfakeproxy-design.md @@ -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`. 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 +```