Files
scadalink-design/deprecated/lmxproxy/docs/lmxproxy_updates.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

26 KiB

LmxProxy Protocol v2 — OPC UA Alignment

This document specifies all changes to the LmxProxy gRPC protocol to align it with OPC UA semantics. The changes replace string-serialized values with typed values and simple quality strings with OPC UA-style status codes.

Baseline: lmxproxy_protocol.md (v1 protocol spec) Strategy: Clean break — all clients and servers updated simultaneously. No backward compatibility layer.


1. Change Summary

Message / Field v1 Type v2 Type Breaking?
VtqMessage.value string TypedValue Yes
VtqMessage.quality string QualityCode Yes
WriteRequest.value string TypedValue Yes
WriteItem.value string TypedValue Yes
WriteBatchAndWaitRequest.flag_value string TypedValue Yes

Unchanged messages: ConnectRequest, ConnectResponse, DisconnectRequest, DisconnectResponse, GetConnectionStateRequest, GetConnectionStateResponse, CheckApiKeyRequest, CheckApiKeyResponse, ReadRequest, ReadBatchRequest, SubscribeRequest, WriteResponse, WriteBatchResponse, WriteBatchAndWaitResponse, WriteResult.

Unchanged RPCs: The ScadaService definition is identical — same RPC names, same request/response pairing. Only the internal message shapes change.


2. Complete Updated Proto File

syntax = "proto3";
package scada;

// ============================================================
// Service Definition (unchanged)
// ============================================================

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);
}

// ============================================================
// NEW: Typed Value System
// ============================================================

// Replaces the v1 string-encoded value field.
// Exactly one field will be set. An unset oneof represents null.
message TypedValue {
  oneof value {
    bool       bool_value     = 1;
    int32      int32_value    = 2;
    int64      int64_value    = 3;
    float      float_value    = 4;
    double     double_value   = 5;
    string     string_value   = 6;
    bytes      bytes_value    = 7;   // byte[]
    int64      datetime_value = 8;   // UTC DateTime.Ticks (100ns intervals since 0001-01-01)
    ArrayValue array_value    = 9;   // arrays of primitives
  }
}

// Container for typed arrays. Exactly one field will be set.
message ArrayValue {
  oneof values {
    BoolArray   bool_values   = 1;
    Int32Array  int32_values  = 2;
    Int64Array  int64_values  = 3;
    FloatArray  float_values  = 4;
    DoubleArray double_values = 5;
    StringArray string_values = 6;
  }
}

message BoolArray   { repeated bool   values = 1; }
message Int32Array  { repeated int32  values = 1; }
message Int64Array  { repeated int64  values = 1; }
message FloatArray  { repeated float  values = 1; }
message DoubleArray { repeated double values = 1; }
message StringArray { repeated string values = 1; }

// ============================================================
// NEW: OPC UA-Style Quality Codes
// ============================================================

// Replaces the v1 string quality field ("Good", "Bad", "Uncertain").
message QualityCode {
  uint32 status_code   = 1;  // OPC UA-compatible numeric status code
  string symbolic_name = 2;  // Human-readable name (e.g., "Good", "BadSensorFailure")
}

// ============================================================
// Connection Lifecycle (unchanged)
// ============================================================

message ConnectRequest {
  string client_id = 1;
  string api_key   = 2;
}

message ConnectResponse {
  bool   success    = 1;
  string message    = 2;
  string session_id = 3;
}

message DisconnectRequest {
  string session_id = 1;
}

message DisconnectResponse {
  bool   success = 1;
  string message = 2;
}

message GetConnectionStateRequest {
  string session_id = 1;
}

message GetConnectionStateResponse {
  bool   is_connected              = 1;
  string client_id                 = 2;
  int64  connected_since_utc_ticks = 3;
}

message CheckApiKeyRequest {
  string api_key = 1;
}

message CheckApiKeyResponse {
  bool   is_valid = 1;
  string message  = 2;
}

// ============================================================
// Value-Timestamp-Quality (CHANGED)
// ============================================================

message VtqMessage {
  string      tag                 = 1;  // Tag address (unchanged)
  TypedValue  value               = 2;  // CHANGED: typed value instead of string
  int64       timestamp_utc_ticks = 3;  // UTC DateTime.Ticks (unchanged)
  QualityCode quality             = 4;  // CHANGED: structured quality instead of string
}

// ============================================================
// Read Operations (request unchanged, response uses new VtqMessage)
// ============================================================

message ReadRequest {
  string session_id = 1;
  string tag        = 2;
}

message ReadResponse {
  bool       success = 1;
  string     message = 2;
  VtqMessage vtq     = 3;  // Uses updated VtqMessage with TypedValue + QualityCode
}

message ReadBatchRequest {
  string          session_id = 1;
  repeated string tags       = 2;
}

message ReadBatchResponse {
  bool                 success = 1;
  string               message = 2;
  repeated VtqMessage  vtqs    = 3;  // Uses updated VtqMessage
}

// ============================================================
// Write Operations (CHANGED: TypedValue instead of string)
// ============================================================

message WriteRequest {
  string     session_id = 1;
  string     tag        = 2;
  TypedValue value      = 3;  // CHANGED from string
}

message WriteResponse {
  bool   success = 1;
  string message = 2;
}

message WriteItem {
  string     tag   = 1;
  TypedValue value = 2;  // CHANGED from string
}

message WriteResult {
  string tag     = 1;
  bool   success = 2;
  string message = 3;
}

message WriteBatchRequest {
  string             session_id = 1;
  repeated WriteItem items      = 2;
}

message WriteBatchResponse {
  bool                 success = 1;
  string               message = 2;
  repeated WriteResult results = 3;
}

// ============================================================
// WriteBatchAndWait (CHANGED: TypedValue for items and flag)
// ============================================================

message WriteBatchAndWaitRequest {
  string             session_id       = 1;
  repeated WriteItem items            = 2;  // Uses updated WriteItem with TypedValue
  string             flag_tag         = 3;
  TypedValue         flag_value       = 4;  // CHANGED from string — type-aware comparison
  int32              timeout_ms       = 5;
  int32              poll_interval_ms = 6;
}

message WriteBatchAndWaitResponse {
  bool                 success       = 1;
  string               message       = 2;
  repeated WriteResult write_results = 3;
  bool                 flag_reached  = 4;
  int32                elapsed_ms    = 5;
}

// ============================================================
// Subscription (request unchanged, stream uses new VtqMessage)
// ============================================================

message SubscribeRequest {
  string          session_id  = 1;
  repeated string tags        = 2;
  int32           sampling_ms = 3;
}

// Returns: stream of VtqMessage (updated with TypedValue + QualityCode)

3. Detailed Change Specifications

3.1 Typed Value Representation

What changed: The string value field throughout the protocol is replaced by TypedValue, a protobuf oneof that carries the value in its native type.

v1 behavior (removed):

  • All values serialized to string via .ToString()
  • Client-side parsing heuristic: numeric → bool → string → null
  • Arrays JSON-serialized as strings (e.g., "[1,2,3]")
  • Empty string treated as null

v2 behavior:

  • Values transmitted in their native protobuf type
  • No parsing ambiguity — the oneof case tells you the type
  • Arrays use dedicated repeated-field messages (Int32Array, FloatArray, etc.)
  • Null represented by an unset oneof (no field selected in TypedValue)
  • datetime_value uses int64 UTC Ticks (same wire encoding as v1 timestamps, but now semantically typed as a DateTime value rather than a string)

Null handling:

Scenario v1 v2
Null value value = "" (empty string) TypedValue with no oneof case set
Missing VTQ Treated as Bad quality, null value Same — Bad quality, unset TypedValue

Type mapping from internal tag model:

Tag Data Type TypedValue Field Notes
bool bool_value
int32 int32_value
int64 int64_value
float float_value
double double_value
string string_value
byte[] bytes_value
DateTime datetime_value UTC Ticks as int64
float[] array_value.float_values
int32[] array_value.int32_values
Other arrays Corresponding ArrayValue field

3.2 OPC UA-Style Quality Codes

What changed: The string quality field (one of "Good", "Uncertain", "Bad") is replaced by QualityCode containing a numeric OPC UA status code and a human-readable symbolic name.

v1 behavior (removed):

  • Quality as case-insensitive string: "Good", "Uncertain", "Bad"
  • No sub-codes — all failures were just "Bad"

v2 behavior:

  • status_code is a uint32 matching OPC UA StatusCode bit layout
  • symbolic_name is the human-readable equivalent (for logging, debugging, display)
  • Category derived from high bits: 0x00xxxxxx = Good, 0x40xxxxxx = Uncertain, 0x80xxxxxx = Bad

Supported quality codes:

The quality codes below are filtered to those actively used by AVEVA System Platform, InTouch, and OI Server/DAServer (per AVEVA Tech Note TN1305). AVEVA's ecosystem maps OPC DA quality codes to OPC UA status codes when communicating over OPC UA. This table includes the OPC UA equivalents for the AVEVA-relevant quality states.

Good Quality:

Symbolic Name Status Code AVEVA OPC DA Hex AVEVA Description
Good 0x00000000 0x00C0 Value is reliable, non-specific
GoodLocalOverride 0x00D80000 0x00D8 Value has been manually overridden; input disconnected

Uncertain Quality:

Symbolic Name Status Code AVEVA OPC DA Hex AVEVA Description
UncertainLastUsableValue 0x40900000 0x0044 External source stopped writing; value is stale
UncertainSensorNotAccurate 0x42390000 0x0050 Sensor out of calibration or clamped at limit
UncertainEngineeringUnitsExceeded 0x40540000 0x0054 Value is outside defined engineering limits
UncertainSubNormal 0x40580000 0x0058 Derived from multiple sources with insufficient good sources

Bad Quality:

Symbolic Name Status Code AVEVA OPC DA Hex AVEVA Description
Bad 0x80000000 0x0000 Non-specific bad; value is not useful
BadConfigurationError 0x80040000 0x0004 Server-specific configuration problem (e.g., item deleted)
BadNotConnected 0x808A0000 0x0008 Input not logically connected to a source
BadDeviceFailure 0x806B0000 0x000C Device failure detected
BadSensorFailure 0x806D0000 0x0010 Sensor failure detected
BadLastKnownValue 0x80050000 0x0014 Communication failed; last known value available (check timestamp age)
BadCommunicationFailure 0x80050000 0x0018 Communication failed; no last known value available
BadOutOfService 0x808F0000 0x001C Block is off-scan or locked; item/group is inactive

Notes:

  • AVEVA OPC DA quality codes use a 16-bit structure: 2 bits major (Good/Bad/Uncertain), 4 bits minor (sub-status), 2 bits limit (Not Limited, Low, High, Constant). The OPC UA status codes above are the standard UA equivalents.
  • The limit bits (Not Limited 0x00, Low Limited 0x01, High Limited 0x02, Constant 0x03) are appended to any quality code. For example, Good + High Limited = 0x00C2 in OPC DA. In OPC UA, limits are conveyed via separate status code bits but the base code remains the same.
  • AVEVA's "Initializing" state (seen when OI Server is still establishing communication) maps to Bad with no sub-code in OPC DA (0x0000). In OPC UA this is BadWaitingForInitialData (0x80320000).
  • This is the minimum set needed to simulate realistic AVEVA System Platform behavior. Additional OPC UA codes can be added if specific simulation scenarios require them.

Category helper logic (C#):

public static string GetCategory(uint statusCode) => statusCode switch
{
    _ when (statusCode & 0xC0000000) == 0x00000000 => "Good",
    _ when (statusCode & 0xC0000000) == 0x40000000 => "Uncertain",
    _ when (statusCode & 0xC0000000) == 0x80000000 => "Bad",
    _ => "Unknown"
};

public static bool IsGood(uint statusCode) => (statusCode & 0xC0000000) == 0x00000000;
public static bool IsBad(uint statusCode)  => (statusCode & 0xC0000000) == 0x80000000;

3.3 WriteBatchAndWait Flag Comparison

What changed: flag_value is now TypedValue instead of string. The server uses type-aware equality comparison instead of string comparison.

v1 behavior (removed):

// v1: string comparison
bool matched = readResult.Value?.ToString() == request.FlagValue;

v2 behavior:

// v2: type-aware comparison
bool matched = TypedValueEquals(readResult.TypedValue, request.FlagValue);

Comparison rules:

  • Both values must have the same oneof case (same type). Mismatched types are never equal.
  • Numeric comparison uses the native type's equality (no floating-point string round-trip issues).
  • String comparison is case-sensitive (unchanged from v1).
  • Bool comparison is direct equality.
  • Null (unset oneof) equals null. Null does not equal any set value.
  • Array comparison: element-by-element equality, same length required.
  • datetime_value compared as int64 equality (tick-level precision).

4. Behavioral Changes

4.1 Read Operations

No RPC signature changes. The returned VtqMessage now uses TypedValue and QualityCode instead of strings.

v1 client code:

var response = await client.ReadAsync(new ReadRequest { SessionId = sid, Tag = "Motor.Speed" });
double value = double.Parse(response.Vtq.Value);          // string → double
bool isGood = response.Vtq.Quality.Equals("Good", ...);   // string comparison

v2 client code:

var response = await client.ReadAsync(new ReadRequest { SessionId = sid, Tag = "Motor.Speed" });
double value = response.Vtq.Value.DoubleValue;                    // direct typed access
bool isGood = response.Vtq.Quality.StatusCode == 0x00000000;      // numeric comparison
// or: bool isGood = IsGood(response.Vtq.Quality.StatusCode);     // helper method

4.2 Write Operations

Client must construct TypedValue instead of converting to string.

v1 client code:

await client.WriteAsync(new WriteRequest
{
    SessionId = sid,
    Tag = "Motor.Speed",
    Value = 42.5.ToString()  // double → string
});

v2 client code:

await client.WriteAsync(new WriteRequest
{
    SessionId = sid,
    Tag = "Motor.Speed",
    Value = new TypedValue { DoubleValue = 42.5 }  // native type
});

4.3 Subscription Stream

No RPC signature changes. The streamed VtqMessage items now use the updated format. Client onUpdate callbacks receive typed values and structured quality.

4.4 Error Conditions with New Quality Codes

The server now returns specific quality codes instead of generic "Bad":

Scenario v1 Quality v2 Quality
Tag not found "Bad" BadConfigurationError (0x80040000)
Tag read exception / comms loss "Bad" BadCommunicationFailure (0x80050000)
Write to read-only tag success=false WriteResult.success=false, message indicates read-only
Type mismatch on write success=false WriteResult.success=false, message indicates type mismatch
Simulated sensor failure "Bad" BadSensorFailure (0x806D0000)
Simulated device failure "Bad" BadDeviceFailure (0x806B0000)
Stale value (fault injection) "Uncertain" UncertainLastUsableValue (0x40900000)
Block off-scan / disabled "Bad" BadOutOfService (0x808F0000)
Local override active "Good" GoodLocalOverride (0x00D80000)
Initializing / waiting for first value "Bad" BadWaitingForInitialData (0x80320000)

5. Migration Guide

5.1 Strategy

Clean break — all clients and servers are updated simultaneously in a single coordinated release. No backward compatibility layer, no version negotiation, no dual-format support.

This is appropriate because:

  • The LmxProxy is an internal protocol between ScadaLink components, not a public API
  • The number of clients is small and controlled
  • Maintaining dual formats adds complexity with no long-term benefit

5.2 Server-Side Changes

Files to update:

File Changes
scada.proto Replace with v2 proto (Section 2 of this document)
ScadaServiceImpl.cs Update all RPC handlers to construct TypedValue and QualityCode instead of strings
SessionManager.cs No changes (session model unchanged)
TagMapper.cs Update to return TypedValue from tag reads instead of string conversion

Server implementation notes:

  • When reading a tag, construct TypedValue by setting the appropriate oneof field based on the tag's data type. Do not call .ToString().
  • When a tag read fails, return QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" } (or a more specific code) instead of the string "Bad".
  • When handling writes, extract the value from the TypedValue oneof and apply it to the tag actor. If the oneof case doesn't match the tag's expected data type, return WriteResult with success=false and message indicating type mismatch.
  • For WriteBatchAndWait flag comparison, implement TypedValueEquals() per the comparison rules in Section 3.3.

5.3 Client-Side Changes

Files to update:

File Changes
ILmxProxyClient.cs Interface unchanged (same method signatures, updated message types come from proto regeneration)
RealLmxProxyClient.cs Update value construction in write methods; update value extraction in read callbacks
LmxProxyDataConnection.cs Update DCL adapter to map between DCL's internal value model and TypedValue/QualityCode
LmxProxyClientFactory.cs No changes

Client implementation notes:

  • Replace all double.Parse(vtq.Value) / bool.Parse(vtq.Value) calls with direct typed access (e.g., vtq.Value.DoubleValue).
  • Replace all vtq.Quality.Equals("Good", ...) string comparisons with numeric status code checks or the IsGood()/IsBad() helpers.
  • Replace all .ToString() value serialization in write paths with TypedValue construction.
  • The onUpdate callback signature in SubscribeAsync doesn't change at the interface level, but the VtqMessage it receives now contains TypedValue and QualityCode.

5.4 Migration Checklist

[ ] Generate updated C# classes from v2 proto file
[ ] Update server: ScadaServiceImpl read handlers → TypedValue + QualityCode
[ ] Update server: ScadaServiceImpl write handlers → accept TypedValue
[ ] Update server: WriteBatchAndWait flag comparison → TypedValueEquals()
[ ] Update server: Error paths → specific QualityCode status codes
[ ] Update client: RealLmxProxyClient read paths → typed value extraction
[ ] Update client: RealLmxProxyClient write paths → TypedValue construction
[ ] Update client: Quality checks → numeric status code comparison
[ ] Update client: LmxProxyDataConnection DCL adapter → map TypedValue ↔ DCL values
[ ] Update all unit tests for new message shapes
[ ] Integration test: client ↔ server round-trip with all data types
[ ] Integration test: WriteBatchAndWait with typed flag comparison
[ ] Integration test: Subscription stream delivers typed VTQ messages
[ ] Integration test: Error paths return correct QualityCode sub-codes
[ ] Remove all string-based value parsing/serialization code
[ ] Remove all string-based quality comparison code

6. Test Scenarios for v2 Validation

These scenarios validate that the v2 protocol behaves correctly across all data types and quality codes.

6.1 Round-Trip Type Fidelity

For each supported data type, write a value via Write, read it back via Read, and verify the TypedValue oneof case and value match exactly:

Data Type Test Value TypedValue Field Verify
bool true bool_value == true
int32 2147483647 int32_value == int.MaxValue
int64 9223372036854775807 int64_value == long.MaxValue
float 3.14159f float_value == 3.14159f (exact bits)
double 2.718281828459045 double_value == 2.718281828459045 (exact bits)
string "Hello World" string_value == "Hello World"
bytes [0x00, 0xFF, 0x42] bytes_value byte-for-byte match
DateTime 638789000000000000L datetime_value == 638789000000000000L
float[] [1.0f, 2.0f, 3.0f] array_value.float_values element-wise match
int32[] [10, 20, 30] array_value.int32_values element-wise match
null (unset) no oneof case Value case == None

6.2 Quality Code Propagation

Scenario Trigger Expected QualityCode
Normal read Read a healthy tag { 0x00000000, "Good" }
Local override Script sets GoodLocalOverride { 0x00D80000, "GoodLocalOverride" }
Fault injection: sensor failure Script sets BadSensorFailure { 0x806D0000, "BadSensorFailure" }
Fault injection: device failure Script sets BadDeviceFailure { 0x806B0000, "BadDeviceFailure" }
Fault injection: stale value Script sets UncertainLastUsableValue { 0x40900000, "UncertainLastUsableValue" }
Fault injection: off-scan Script sets BadOutOfService { 0x808F0000, "BadOutOfService" }
Fault injection: comms failure Script sets BadCommunicationFailure { 0x80050000, "BadCommunicationFailure" }
Unknown tag Read nonexistent tag { 0x80040000, "BadConfigurationError" }
Write to read-only Write to a read-only tag WriteResult.success=false, message contains "read-only"

6.3 WriteBatchAndWait Typed Flag Comparison

Flag Type Written Value Flag Value Expected Result
bool true TypedValue { bool_value = true } flag_reached = true
bool false TypedValue { bool_value = true } flag_reached = false (timeout)
double 42.5 TypedValue { double_value = 42.5 } flag_reached = true
double 42.500001 TypedValue { double_value = 42.5 } flag_reached = false
string "DONE" TypedValue { string_value = "DONE" } flag_reached = true
string "done" TypedValue { string_value = "DONE" } flag_reached = false (case-sensitive)
int32 1 TypedValue { double_value = 1.0 } flag_reached = false (type mismatch)

6.4 Subscription Stream

  • Subscribe to tags of mixed data types
  • Verify each streamed VtqMessage has the correct oneof case matching the tag's data type
  • Inject a fault mid-stream and verify the quality code changes from Good to the injected code
  • Cancel the subscription and verify the stream terminates cleanly

7. Appendix: v1 → v2 Quick Reference

Reading a value:

// v1
string raw = vtq.Value;
if (double.TryParse(raw, out var d)) { /* use d */ }
else if (bool.TryParse(raw, out var b)) { /* use b */ }
else { /* it's a string */ }

// v2
switch (vtq.Value.ValueCase)
{
    case TypedValue.ValueOneofCase.DoubleValue:
        double d = vtq.Value.DoubleValue;
        break;
    case TypedValue.ValueOneofCase.BoolValue:
        bool b = vtq.Value.BoolValue;
        break;
    case TypedValue.ValueOneofCase.StringValue:
        string s = vtq.Value.StringValue;
        break;
    case TypedValue.ValueOneofCase.None:
        // null value
        break;
    // ... other cases
}

Writing a value:

// v1
new WriteItem { Tag = "Motor.Speed", Value = 42.5.ToString() }

// v2
new WriteItem { Tag = "Motor.Speed", Value = new TypedValue { DoubleValue = 42.5 } }

Checking quality:

// v1
bool isGood = vtq.Quality.Equals("Good", StringComparison.OrdinalIgnoreCase);
bool isBad  = vtq.Quality.Equals("Bad", StringComparison.OrdinalIgnoreCase);

// v2
bool isGood = (vtq.Quality.StatusCode & 0xC0000000) == 0x00000000;
bool isBad  = (vtq.Quality.StatusCode & 0xC0000000) == 0x80000000;
// or use helper:
bool isGood = QualityHelper.IsGood(vtq.Quality.StatusCode);

Constructing quality (server-side):

// v1
vtq.Quality = "Good";

// v2
vtq.Quality = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
// or for errors:
vtq.Quality = new QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" };
vtq.Quality = new QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" };
vtq.Quality = new QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" };

Document version: 1.0 — All decisions resolved. Complete proto, migration guide, and test scenarios.