LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
229 lines
9.2 KiB
Markdown
229 lines
9.2 KiB
Markdown
# 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:**
|
|
- `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
|
|
```
|