deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
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.
This commit is contained in:
@@ -1,228 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-19-lmxfakeproxy-implementation.md",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeId": "3", "subject": "Task 1: Project Scaffolding", "status": "pending"},
|
||||
{"id": 2, "nativeId": "4", "subject": "Task 2: TagMapper Utility + Tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": "5", "subject": "Task 3: SessionManager + Tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": "6", "subject": "Task 4: IOpcUaBridge + OpcUaBridge Implementation", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeId": "7", "subject": "Task 5: ScadaServiceImpl + Tests", "status": "pending", "blockedBy": [2, 3, 4]},
|
||||
{"id": 6, "nativeId": "8", "subject": "Task 6: Program.cs Host Builder", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": "9", "subject": "Task 7: Dockerfile + Docker Compose", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": "10", "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "nativeId": "11", "subject": "Task 9: Integration Smoke Test", "status": "pending", "blockedBy": [5, 7]},
|
||||
{"id": 10, "nativeId": "12", "subject": "Task 10: End-to-End Verification", "status": "pending", "blockedBy": [7, 8, 9]}
|
||||
],
|
||||
"lastUpdated": "2026-03-19T00:00:00Z"
|
||||
}
|
||||
Reference in New Issue
Block a user