# LmxProxy Protocol Specification The LmxProxy protocol is a gRPC-based SCADA read/write interface for bridging ScadaLink's Data Connection Layer to devices via an intermediary proxy server (LmxProxy). The proxy translates LmxProxy protocol operations into backend device calls (e.g., OPC UA). All communication uses HTTP/2 gRPC with Protocol Buffers. ## Service Definition ```protobuf syntax = "proto3"; package scada; service ScadaService { rpc Connect(ConnectRequest) returns (ConnectResponse); rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); rpc Read(ReadRequest) returns (ReadResponse); rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); rpc Write(WriteRequest) returns (WriteResponse); rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); } ``` Proto file location: `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` ## Connection Lifecycle ### Session Model Every client must call `Connect` before performing any read, write, or subscribe operation. The server returns a session ID (32-character hex GUID) that must be included in all subsequent requests. Sessions persist until `Disconnect` is called or the server restarts — there is no idle timeout. ### Authentication API key authentication is optional, controlled by server configuration: - **If required**: The `Connect` RPC fails with `success=false` if the API key doesn't match. - **If not required**: All API keys are accepted (including empty). - The API key is sent both in the `ConnectRequest.api_key` field and as an `x-api-key` gRPC metadata header on the `Connect` call. ### Connect ``` ConnectRequest { client_id: string // Client identifier (e.g., "ScadaLink-{guid}") api_key: string // API key for authentication (empty if none) } ConnectResponse { success: bool // Whether connection succeeded message: string // Status message session_id: string // 32-char hex GUID (only valid if success=true) } ``` The client generates `client_id` as `"ScadaLink-{Guid:N}"` for uniqueness. ### Disconnect ``` DisconnectRequest { session_id: string } DisconnectResponse { success: bool message: string } ``` Best-effort — the client calls disconnect but does not retry on failure. ### GetConnectionState ``` GetConnectionStateRequest { session_id: string } GetConnectionStateResponse { is_connected: bool client_id: string connected_since_utc_ticks: int64 // DateTime.UtcNow.Ticks at connect time } ``` ### CheckApiKey ``` CheckApiKeyRequest { api_key: string } CheckApiKeyResponse { is_valid: bool message: string } ``` Standalone API key validation without creating a session. ## Value-Timestamp-Quality (VTQ) The core data structure for all read and subscription results: ``` VtqMessage { tag: string // Tag address value: string // Value encoded as string (see Value Encoding) timestamp_utc_ticks: int64 // UTC DateTime.Ticks (100ns intervals since 0001-01-01) quality: string // "Good", "Uncertain", or "Bad" } ``` ### Value Encoding All values are transmitted as strings on the wire. Both client and server use the same parsing order: | Wire String | Parsed Type | Example | |-------------|------------|---------| | Numeric (double-parseable) | `double` | `"42.5"` → `42.5` | | `"true"` / `"false"` (case-insensitive) | `bool` | `"True"` → `true` | | Everything else | `string` | `"Running"` → `"Running"` | | Empty string | `null` | `""` → `null` | For write operations, values are converted to strings via `.ToString()` before transmission. Arrays and lists are JSON-serialized (e.g., `[1,2,3]`). ### Quality Codes Quality is transmitted as a case-insensitive string: | Wire Value | Meaning | OPC UA Status Code | |-----------|---------|-------------------| | `"Good"` | Value is reliable | `0x00000000` (StatusCode == 0) | | `"Uncertain"` | Value may not be current | Non-zero, high bit clear | | `"Bad"` | Value is unreliable or unavailable | High bit set (`0x80000000`) | A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp. ### Timestamps - All timestamps are UTC. - Encoded as `int64` representing `DateTime.Ticks` (100-nanosecond intervals since 0001-01-01 00:00:00 UTC). - Client reconstructs via `new DateTime(ticks, DateTimeKind.Utc)`. ## Read Operations ### Read (Single Tag) ``` ReadRequest { session_id: string // Valid session ID tag: string // Tag address } ReadResponse { success: bool // Whether read succeeded message: string // Error message if failed vtq: VtqMessage // Value-timestamp-quality result } ``` ### ReadBatch (Multiple Tags) ``` ReadBatchRequest { session_id: string tags: repeated string // Tag addresses } ReadBatchResponse { success: bool // false if any tag failed message: string // Error message vtqs: repeated VtqMessage // Results in same order as request } ``` Batch reads are **partially successful** — individual tags may have Bad quality while the overall response succeeds. If a tag read throws an exception, its VTQ is returned with Bad quality and current UTC timestamp. ## Write Operations ### Write (Single Tag) ``` WriteRequest { session_id: string tag: string value: string // Value as string (parsed server-side) } WriteResponse { success: bool message: string } ``` ### WriteBatch (Multiple Tags) ``` WriteItem { tag: string value: string } WriteResult { tag: string success: bool message: string } WriteBatchRequest { session_id: string items: repeated WriteItem } WriteBatchResponse { success: bool // Overall success (all items must succeed) message: string results: repeated WriteResult // Per-item results } ``` Batch writes are **all-or-nothing** at the reporting level — if any item fails, overall `success` is `false`. ### WriteBatchAndWait (Atomic Write + Flag Polling) A compound operation: write values, then poll a flag tag until it matches an expected value or times out. ``` WriteBatchAndWaitRequest { session_id: string items: repeated WriteItem // Values to write flag_tag: string // Tag to poll after writes flag_value: string // Expected value (string comparison) timeout_ms: int32 // Timeout in ms (default 5000 if ≤ 0) poll_interval_ms: int32 // Poll interval in ms (default 100 if ≤ 0) } WriteBatchAndWaitResponse { success: bool // Overall operation success message: string write_results: repeated WriteResult // Per-item write results flag_reached: bool // Whether flag matched before timeout elapsed_ms: int32 // Total elapsed time } ``` **Behavior:** 1. All writes execute first. If any write fails, the operation returns immediately with `success=false`. 2. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals. 3. Compares `readResult.Value?.ToString() == flag_value` (case-sensitive string comparison). 4. If flag matches before timeout: `success=true`, `flag_reached=true`. 5. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error). ## Subscription (Server Streaming) ### Subscribe ``` SubscribeRequest { session_id: string tags: repeated string // Tag addresses to monitor sampling_ms: int32 // Backend sampling interval in milliseconds } // Returns: stream of VtqMessage ``` **Behavior:** 1. Server validates the session. Invalid session → `RpcException` with `StatusCode.Unauthenticated`. 2. Server registers monitored items on the backend (e.g., OPC UA subscriptions) for all requested tags. 3. On each value change, the server pushes a `VtqMessage` to the response stream. 4. The stream remains open indefinitely until: - The client cancels (disposes the subscription). - The server encounters an error (backend disconnect, etc.). - The gRPC connection drops. 5. On stream termination, the client's `onStreamError` callback fires exactly once. **Client-side subscription lifecycle:** ``` ILmxSubscription subscription = await client.SubscribeAsync( addresses: ["Motor.Speed", "Motor.Temperature"], onUpdate: (tag, vtq) => { /* handle value change */ }, onStreamError: () => { /* handle disconnect */ }); // Later: await subscription.DisposeAsync(); // Cancels the stream ``` Disposing the subscription cancels the underlying `CancellationTokenSource`, which terminates the background stream-reading task and triggers server-side cleanup of monitored items. ## Tag Addressing Tags are string addresses that identify data points. The proxy maps tag addresses to backend-specific identifiers. **LmxFakeProxy example** (OPC UA backend): Tag addresses are concatenated with a configurable prefix to form OPC UA node IDs: ``` Prefix: "ns=3;s=" Tag: "Motor.Speed" NodeId: "ns=3;s=Motor.Speed" ``` The prefix is configured at server startup via the `OPC_UA_PREFIX` environment variable. ## Transport Details | Setting | Value | |---------|-------| | Protocol | gRPC over HTTP/2 | | Default port | 50051 | | TLS | Optional (controlled by `UseTls` connection parameter) | | Metadata headers | `x-api-key` (sent on Connect call if API key configured) | ### Connection Parameters The ScadaLink DCL configures LmxProxy connections via a string dictionary: | Key | Type | Default | Description | |-----|------|---------|-------------| | `Host` | string | `"localhost"` | gRPC server hostname | | `Port` | string (parsed as int) | `"50051"` | gRPC server port | | `ApiKey` | string | (none) | API key for authentication | | `SamplingIntervalMs` | string (parsed as int) | `"0"` | Backend sampling interval for subscriptions | | `UseTls` | string (parsed as bool) | `"false"` | Use HTTPS instead of HTTP | ## Error Handling | Operation | Error Mechanism | Client Behavior | |-----------|----------------|-----------------| | Connect | `success=false` in response | Throws `InvalidOperationException` | | Read/ReadBatch | `success=false` in response | Throws `InvalidOperationException` | | Write/WriteBatch | `success=false` in response | Throws `InvalidOperationException` | | WriteBatchAndWait | `success=false` or `flag_reached=false` | Returns result (timeout is not an exception) | | Subscribe (auth) | `RpcException` with `Unauthenticated` | Propagated to caller | | Subscribe (stream) | Stream ends or gRPC error | `onStreamError` callback invoked; `sessionId` nullified | | Any (disconnected) | Client checks `IsConnected` | Throws `InvalidOperationException("not connected")` | When a subscription stream ends unexpectedly, the client immediately nullifies its session ID, causing `IsConnected` to return `false`. The DCL adapter fires its `Disconnected` event, which triggers the reconnection cycle in the `DataConnectionActor`. ## Implementation Files | Component | File | |-----------|------| | Proto definition | `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` | | Client interface | `src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs` | | Client implementation | `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` | | DCL adapter | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs` | | Client factory | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyClientFactory.cs` | | Server implementation | `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` | | Session manager | `infra/lmxfakeproxy/Sessions/SessionManager.cs` | | Tag mapper | `infra/lmxfakeproxy/TagMapper.cs` | | OPC UA bridge interface | `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` | | OPC UA bridge impl | `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` |