Files
scadalink-design/deprecated/lmxproxy/docs/lmxproxy_protocol.md
Joseph Doherty 9dccf8e72f 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.
2026-04-08 15:56:23 -04:00

12 KiB

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

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