feat: add standalone LmxProxy solution, windev VM documentation

Split LmxProxy Host and Client into a self-contained solution under lmxproxy/,
ported from the ScadaBridge monorepo with updated namespaces (ZB.MOM.WW.LmxProxy.*).
Client project (.NET 10) inlines Core/DataEngine dependencies and builds clean.
Host project (.NET Fx 4.8) retains ArchestrA.MXAccess for Windows deployment.
Added windev.md documenting the WW_DEV_VM development environment setup.
This commit is contained in:
Joseph Doherty
2026-03-21 20:50:05 -04:00
parent 512153646a
commit 2810306415
64 changed files with 11276 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
# 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` |

View File

@@ -0,0 +1,647 @@
# 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
```protobuf
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#):**
```csharp
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):**
```csharp
// v1: string comparison
bool matched = readResult.Value?.ToString() == request.FlagValue;
```
**v2 behavior:**
```csharp
// 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:**
```csharp
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:**
```csharp
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:**
```csharp
await client.WriteAsync(new WriteRequest
{
SessionId = sid,
Tag = "Motor.Speed",
Value = 42.5.ToString() // double → string
});
```
**v2 client code:**
```csharp
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 TagSim simulator is a new implementation, not an upgrade of an existing server
- 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:**
```csharp
// 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:**
```csharp
// v1
new WriteItem { Tag = "Motor.Speed", Value = 42.5.ToString() }
// v2
new WriteItem { Tag = "Motor.Speed", Value = new TypedValue { DoubleValue = 42.5 } }
```
**Checking quality:**
```csharp
// 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):**
```csharp
// 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.*