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:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

@@ -32,7 +32,7 @@ Both central and site clusters.
- The Site Runtime Deployment Manager runs as an **Akka.NET cluster singleton** on the active node, owning the full Instance Actor hierarchy.
- One standby node receives replicated store-and-forward data and is ready to take over.
- Connected to local SQLite databases (store-and-forward buffer, event logs, deployed configurations).
- Connected to machines via data connections (OPC UA, LmxProxy).
- Connected to machines via data connections (OPC UA).
## Failover Behavior

View File

@@ -10,7 +10,7 @@ Site clusters only. Central does not interact with machines directly.
## Responsibilities
- Manage data connections defined centrally and deployed to sites as part of artifact deployment (OPC UA servers, LmxProxy endpoints). Data connection definitions are stored in local SQLite after deployment.
- Manage data connections defined centrally and deployed to sites as part of artifact deployment (OPC UA servers). Data connection definitions are stored in local SQLite after deployment.
- Establish and maintain connections to data sources based on deployed instance configurations.
- Subscribe to tag paths as requested by Instance Actors (based on attribute data source references in the flattened configuration).
- Deliver tag value updates to the requesting Instance Actors.
@@ -19,7 +19,7 @@ Site clusters only. Central does not interact with machines directly.
## Common Interface
Both OPC UA and LmxProxy implement the same interface:
All protocol adapters implement the same interface:
```
IDataConnection : IAsyncDisposable
@@ -38,52 +38,16 @@ IDataConnection : IAsyncDisposable
The `Disconnected` event is raised by an adapter when it detects an unexpected connection loss (server offline, network failure, keep-alive timeout). The `DataConnectionActor` subscribes to this event to trigger the reconnection state machine. Additional protocols can be added by implementing this interface.
### Concrete Type Mappings
| IDataConnection | OPC UA SDK | LmxProxy (`RealLmxProxyClient`) |
|---|---|---|
| `Connect()` | OPC UA session establishment | gRPC `Connect` RPC with `x-api-key` metadata header, server returns `SessionId` |
| `Disconnect()` | Close OPC UA session | gRPC `Disconnect` RPC |
| `Subscribe(tagPath, callback)` | OPC UA Monitored Items | gRPC `Subscribe` server-streaming RPC (`stream VtqMessage`), cancelled via `CancellationTokenSource` |
| `Unsubscribe(id)` | Remove Monitored Item | Cancel the `CancellationTokenSource` for that subscription (stops streaming RPC) |
| `Read(tagPath)` | OPC UA Read | gRPC `Read` RPC → `VtqMessage``LmxVtq` |
| `ReadBatch(tagPaths)` | OPC UA Read (multiple nodes) | gRPC `ReadBatch` RPC → `repeated VtqMessage``IDictionary<string, LmxVtq>` |
| `Write(tagPath, value)` | OPC UA Write | gRPC `Write` RPC (throws on failure) |
| `WriteBatch(values)` | OPC UA Write (multiple nodes) | gRPC `WriteBatch` RPC (throws on failure) |
| `WriteBatchAndWait(...)` | OPC UA Write + poll for confirmation | `WriteBatch` + poll `Read` at 100ms intervals until response value matches or timeout |
| `Status` | OPC UA session state | `IsConnected` — true when `SessionId` is non-empty |
| `Disconnected` | `Session.KeepAlive` event fires with bad `ServiceResult` | gRPC subscription stream ends or throws non-cancellation `RpcException` |
### Common Value Type
Both protocols produce the same value tuple consumed by Instance Actors. Before the first value update arrives from the DCL, data-sourced attributes are held at **uncertain** quality by the Instance Actor (see Site Runtime — Initialization):
All protocols produce the same value tuple consumed by Instance Actors. Before the first value update arrives from the DCL, data-sourced attributes are held at **uncertain** quality by the Instance Actor (see Site Runtime — Initialization):
| Concept | ScadaLink Design | LmxProxy Wire Format | Local Type |
|---|---|---|---|
| Value container | `TagValue(Value, Quality, Timestamp)` | `VtqMessage { Tag, TypedValue, TimestampUtcTicks, QualityCode }` | `LmxVtq(Value, TimestampUtc, Quality)` — readonly record struct |
| Quality | `QualityCode` enum: Good / Bad / Uncertain | `QualityCode` enum (OPC-style byte values) | Mapped via `IsGood()`/`IsUncertain()`/`IsBad()` extensions |
| Timestamp | `DateTimeOffset` (UTC) | `int64` (DateTime.Ticks, UTC) | `DateTime` (UTC) |
| Value type | `object?` | `TypedValue` (protobuf oneof: bool, int32, int64, float, double, string, datetime, array) | `object?` (native typed from SDK's `Vtq.Value`) |
### Value Serialization
**Inbound (reads/subscriptions)**: The LmxProxy SDK returns `Vtq(object? Value, DateTime Timestamp, Quality Quality)`. The adapter's `NormalizeValue` converts complex types (`ArrayValue`, raw arrays) to display strings before passing them into the ScadaLink system. Scalar types (`bool`, `int`, `double`, `string`, `DateTime`) pass through unchanged.
**Outbound (writes)**: The adapter's `ToTypedValue` converts `object?` values to the SDK's `TypedValue` for native typed writes:
| Source Type | TypedValue Variant |
| Concept | ScadaLink Design |
|---|---|
| `bool` | `BoolValue` |
| `int` | `Int32Value` |
| `long` | `Int64Value` |
| `float` | `FloatValue` |
| `double` | `DoubleValue` |
| `string` | `StringValue` |
| `DateTime` | `DatetimeValue` (UTC ticks) |
| `null` | `StringValue` (empty) |
| fallback | `StringValue` (`.ToString()`) |
**Array normalization**: `ValueFormatter.FormatDisplayValue` uses reflection to extract typed array contents from LmxProxy `ArrayValue` objects and formats them as comma-separated strings. This ensures downstream code (Instance Actors, debug views, gRPC streaming) never sees opaque SDK types.
| Value container | `TagValue(Value, Quality, Timestamp)` |
| Quality | `QualityCode` enum: Good / Bad / Uncertain |
| Timestamp | `DateTimeOffset` (UTC) |
| Value type | `object?` |
## Supported Protocols
@@ -94,36 +58,6 @@ Both protocols produce the same value tuple consumed by Instance Actors. Before
- Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping.
- Disconnect detection via `Session.KeepAlive` event (see Disconnect Detection Pattern below).
### LmxProxy (Custom Protocol)
LmxProxy is a gRPC-based protocol for communicating with LMX data servers. The DCL uses the real `ZB.MOM.WW.LmxProxy.Client` SDK library via project reference. `RealLmxProxyClient` is a thin adapter wrapper around the SDK client for testability — it implements a local `ILmxProxyClient` interface while delegating to the SDK. The SDK handles gRPC channel management, retry policies (Polly), keep-alive, and TLS.
**Transport & Connection**:
- gRPC over HTTP/2 via the SDK's managed channel.
- Default port: **50051**.
- Session-based: `Connect` RPC returns a `SessionId` used for all subsequent operations.
- Keep-alive: Managed by the LmxProxy server's session timeout. The DCL reconnect cycle handles session loss.
**Authentication & TLS**:
- API key-based authentication sent as `x-api-key` gRPC metadata header on every call. The server's `ApiKeyInterceptor` validates the header before the request reaches the service method. The API key is also included in the `ConnectRequest` body for session-level validation.
- Plain HTTP/2 (no TLS) for current deployments. The server supports TLS when configured.
**Subscriptions**:
- Server-streaming gRPC (`Subscribe` RPC returns `stream VtqMessage`).
- Configurable sampling interval (default: 0 = on-change).
- Wire format: `VtqMessage` with `TypedValue` (protobuf oneof) for value, `QualityCode` enum for quality — see [lmxproxy_protocol.md](lmxproxy_protocol.md) for wire details.
- Subscription lifetime managed by `CancellationTokenSource` — cancellation stops the streaming RPC.
**Client Implementation** (`RealLmxProxyClient`):
- Thin adapter over the `ZB.MOM.WW.LmxProxy.Client` SDK. Implements `ILmxProxyClient` for testability.
- `ILmxProxyClientFactory` creates instances configured with host, port, and API key.
- Read values arrive as native typed objects from the SDK's `Vtq.Value` — no string parsing needed.
- Quality mapping: SDK's `Quality` enum (OPC-style byte values) mapped to ScadaLink's `QualityCode` via `IsGood()`/`IsUncertain()`/`IsBad()` extension methods.
**Proto Source**: The `.proto` file originates from the LmxProxy server repository (`lmx/Proxy/Grpc/Protos/scada.proto` in ScadaBridge). The SDK provides the generated client stubs.
**Test Infrastructure**: The `infra/lmxfakeproxy/` project provides a fake LmxProxy server that bridges to the OPC UA test server. It implements the full `scada.ScadaService` proto, enabling end-to-end testing of `RealLmxProxyClient` without a Windows LmxProxy deployment. See [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) for setup.
## Endpoint Redundancy
Data connections support an optional backup endpoint for automatic failover when the active endpoint becomes unreachable. Both endpoints use the same protocol.
@@ -181,16 +115,6 @@ All settings are parsed from the data connection's configuration JSON dictionari
| `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` |
| `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates |
### LmxProxy Settings
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `Host` | string | `localhost` | LmxProxy server hostname |
| `Port` | int | `50051` | LmxProxy gRPC port |
| `ApiKey` | string | *(none)* | API key for `x-api-key` header authentication |
| `SamplingIntervalMs` | int | `0` | Subscription sampling interval: 0 = on-change, >0 = time-based (ms) |
| `UseTls` | bool | `false` | Use HTTPS instead of plain HTTP/2 for gRPC channel |
### Shared Settings (appsettings.json)
These are configured via `DataConnectionOptions` in `appsettings.json`, not per-connection:
@@ -200,7 +124,6 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per-
| `ReconnectInterval` | 5s | Fixed interval between reconnection attempts |
| `TagResolutionRetryInterval` | 10s | Retry interval for unresolved tag paths |
| `WriteTimeout` | 30s | Timeout for write operations |
| `LmxProxyKeepAliveInterval` | 30s | Keep-alive ping interval for LmxProxy sessions |
## Subscription Management
@@ -235,8 +158,6 @@ Each data connection is managed by a dedicated connection actor that uses the Ak
This pattern ensures no messages are lost during connection transitions and is the standard Akka.NET approach for actors with I/O lifecycle dependencies.
**LmxProxy-specific notes**: The `RealLmxProxyClient` holds the `SessionId` returned by the `Connect` RPC and includes it in all subsequent operations. Subscriptions use server-streaming gRPC — a background task reads from the `ResponseStream` and invokes the callback for each `VtqMessage`. When the stream breaks (server offline, network failure), the background task detects the `RpcException` or stream end and invokes the `onStreamError` callback, which triggers the adapter's `Disconnected` event. The DCL actor transitions to **Reconnecting**, pushes bad quality, disposes the client, and retries at the fixed interval.
**OPC UA-specific notes**: The `RealOpcUaClient` uses the OPC Foundation SDK's `Session.KeepAlive` event for proactive disconnect detection. The SDK sends keep-alive requests at the subscription's `KeepAliveCount × PublishingInterval` (default: 10s). When keep-alive fails, the `ConnectionLost` event fires, triggering the same reconnection flow. On reconnection, the DCL re-creates the OPC UA session and subscription, then re-adds all monitored items.
## Connection Lifecycle & Reconnection
@@ -254,14 +175,13 @@ Each adapter implements the `IDataConnection.Disconnected` event to proactively
**Proactive detection** (server goes offline between operations):
- **OPC UA**: The OPC Foundation SDK fires `Session.KeepAlive` events at regular intervals. `RealOpcUaClient` hooks this event; when `ServiceResult.IsBad(e.Status)` (server unreachable, keep-alive timeout), it fires `ConnectionLost`. The `OpcUaDataConnection` adapter translates this into `IDataConnection.Disconnected`.
- **LmxProxy**: gRPC server-streaming subscriptions run in background tasks reading from `ResponseStream`. When the server goes offline, the stream either ends normally (server closed) or throws a non-cancellation `RpcException`. `RealLmxProxyClient` invokes the `onStreamError` callback, which `LmxProxyDataConnection` translates into `IDataConnection.Disconnected`.
**Reactive detection** (failure discovered during an operation):
- Both adapters wrap `ReadAsync` (and by extension `ReadBatchAsync`) with exception handling. If a read throws a non-cancellation exception, the adapter calls `RaiseDisconnected()` and re-throws. The `DataConnectionActor`'s existing error handling catches the exception while the disconnect event triggers the reconnection state machine.
**Event marshalling**: The `DataConnectionActor` subscribes to `_adapter.Disconnected` in `PreStart()`. Since `Disconnected` may fire from a background thread (gRPC stream task, OPC UA keep-alive timer), the handler sends an `AdapterDisconnected` message to `Self`, marshalling the notification onto the actor's message loop. This triggers `BecomeReconnecting()` → bad quality push → retry timer.
**Once-only guard**: Both `LmxProxyDataConnection` and `OpcUaDataConnection` use a `volatile bool _disconnectFired` flag to ensure `RaiseDisconnected()` fires exactly once per connection session. The flag resets on successful reconnection (`ConnectAsync`).
**Once-only guard**: `OpcUaDataConnection` uses a `volatile bool _disconnectFired` flag to ensure `RaiseDisconnected()` fires exactly once per connection session. The flag resets on successful reconnection (`ConnectAsync`).
## Write Failure Handling

View File

@@ -60,8 +60,8 @@
- Store-and-forward buffers are persisted to a **local SQLite database on each node** and replicated between nodes via application-level replication (see 1.3).
### 2.4 Data Connection Protocols
- The system supports **OPC UA** and **LmxProxy** (a gRPC-based custom protocol with an existing client SDK).
- Both protocols implement a **common interface** supporting: connect, subscribe to tag paths, receive value updates, and write values.
- The system supports **OPC UA** as the primary data connection protocol.
- All protocols implement a **common interface** supporting: connect, subscribe to tag paths, receive value updates, and write values.
- Additional protocols can be added by implementing the common interface.
- The Data Connection Layer is a **clean data pipe** — it publishes tag value updates to Instance Actors but performs no evaluation of triggers or alarm conditions.
- **Initial attribute quality**: Attributes bound to a data connection start with **uncertain** quality when the Instance Actor initializes. The quality remains uncertain until the first value update is received from the Data Connection Layer. This distinguishes "never received a value" from "received a known-good value" or "connection lost" (bad quality).

View File

@@ -1,388 +0,0 @@
# LmxProxy Protocol Specification
> **Note:** This specification reflects the v2 protocol with native `TypedValue` support. The original v1 string-based protocol (string values, string quality) has been replaced.
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: TypedValue // Native typed value (protobuf oneof)
timestamp_utc_ticks: int64 // UTC DateTime.Ticks (100ns intervals since 0001-01-01)
quality: QualityCode // OPC UA status code + symbolic name
}
```
### TypedValue
Values are transmitted as native types via a protobuf `oneof`:
| Oneof Variant | Proto Type | .NET Type |
|---|---|---|
| `bool_value` | `bool` | `bool` |
| `int32_value` | `int32` | `int` |
| `int64_value` | `int64` | `long` |
| `float_value` | `float` | `float` |
| `double_value` | `double` | `double` |
| `string_value` | `string` | `string` |
| `bytes_value` | `bytes` | `byte[]` |
| `datetime_value` | `int64` | `DateTime` (UTC ticks) |
| `array_value` | `ArrayValue` | See below |
### ArrayValue
`ArrayValue` contains typed sub-arrays via a protobuf `oneof`:
| Sub-array | Element Type |
|---|---|
| `BoolArray` | `repeated bool` |
| `Int32Array` | `repeated int32` |
| `Int64Array` | `repeated int64` |
| `FloatArray` | `repeated float` |
| `DoubleArray` | `repeated double` |
| `StringArray` | `repeated string` |
> **Note:** `DateTime` arrays are not natively supported in the proto — they are serialized as `Int64Array` (UTC ticks) by the Host.
The ScadaLink adapter normalizes `ArrayValue` objects to comma-separated display strings at the adapter boundary (see [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md#value-serialization)).
### Value Encoding (v1 — deprecated)
The v1 protocol transmitted all values as strings with client-side parsing (`double.TryParse`, `bool.TryParse`). This has been replaced by native `TypedValue`. The v1 heuristics are no longer used.
### Quality Codes
Quality is transmitted as a `QualityCode` enum with OPC UA status code semantics:
| QualityCode | Meaning | OPC UA Mapping |
|---|---|---|
| Good | Value is reliable | StatusCode high bits clear |
| Uncertain | Value may not be current | Non-zero, high bit clear |
| Bad | Value is unreliable or unavailable | High bit set (`0x80000000`) |
The SDK provides `IsGood()`, `IsUncertain()`, and `IsBad()` extension methods on the `Quality` enum. The adapter maps these to ScadaLink's `QualityCode`.
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: TypedValue // Native typed value (see TypedValue)
}
WriteResponse {
success: bool
message: string
}
```
The client adapter's `ToTypedValue` method converts `object?` values to the appropriate `TypedValue` variant before transmission. See [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md#value-serialization) for the mapping table.
### WriteBatch (Multiple Tags)
```
WriteItem {
tag: string
value: TypedValue
}
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 (TypedValue)
flag_tag: string // Tag to poll after writes
flag_value: TypedValue // Expected value (typed 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 the read result's `TypedValue` against `flag_value`.
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` |