# 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
```
