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,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
<Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
</Folder>
</Solution>

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.*

Binary file not shown.

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// TLS configuration for LmxProxy client connections
/// </summary>
public class ClientTlsConfiguration
{
/// <summary>
/// Gets or sets whether to use TLS for the connection
/// </summary>
public bool UseTls { get; set; } = false;
/// <summary>
/// Gets or sets the path to the client certificate file (optional for mutual TLS)
/// </summary>
public string? ClientCertificatePath { get; set; }
/// <summary>
/// Gets or sets the path to the client private key file (optional for mutual TLS)
/// </summary>
public string? ClientKeyPath { get; set; }
/// <summary>
/// Gets or sets the path to the CA certificate for server validation (optional)
/// </summary>
public string? ServerCaCertificatePath { get; set; }
/// <summary>
/// Gets or sets the server name override for certificate validation (optional)
/// </summary>
public string? ServerNameOverride { get; set; }
/// <summary>
/// Gets or sets whether to validate the server certificate
/// </summary>
public bool ValidateServerCertificate { get; set; } = true;
/// <summary>
/// Gets or sets whether to allow self-signed certificates (for testing only)
/// </summary>
public bool AllowSelfSignedCertificates { get; set; } = false;
/// <summary>
/// Gets or sets whether to ignore all certificate errors (DANGEROUS - for testing only)
/// WARNING: This completely disables certificate validation and should never be used in production
/// </summary>
public bool IgnoreAllCertificateErrors { get; set; } = false;
}

View File

@@ -0,0 +1,49 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Represents the connection state of an LmxProxy client.
/// </summary>
public enum ConnectionState
{
/// <summary>Not connected to the server.</summary>
Disconnected,
/// <summary>Connection attempt in progress.</summary>
Connecting,
/// <summary>Connected and ready for operations.</summary>
Connected,
/// <summary>Graceful disconnect in progress.</summary>
Disconnecting,
/// <summary>Connection failed with an error.</summary>
Error,
/// <summary>Attempting to re-establish a lost connection.</summary>
Reconnecting
}
/// <summary>
/// Event arguments for connection state change notifications.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>The previous connection state.</summary>
public ConnectionState OldState { get; }
/// <summary>The new connection state.</summary>
public ConnectionState NewState { get; }
/// <summary>Optional message describing the state change (e.g., error details).</summary>
public string? Message { get; }
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null)
{
OldState = oldState;
NewState = newState;
Message = message;
}
}

View File

@@ -0,0 +1,118 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// OPC-style quality codes for SCADA data values.
/// Based on OPC DA quality encoding as a single byte:
/// bits 76 = major (00=Bad, 01=Uncertain, 11=Good),
/// bits 52 = substatus, bits 10 = limit (00=None, 01=Low, 10=High, 11=Constant).
/// </summary>
public enum Quality : byte
{
/// <summary>Bad non-specific.</summary>
Bad = 0,
/// <summary>Bad configuration error in the server.</summary>
Bad_ConfigError = 4,
/// <summary>Bad input source is not connected.</summary>
Bad_NotConnected = 8,
/// <summary>Bad device failure detected.</summary>
Bad_DeviceFailure = 12,
/// <summary>Bad sensor failure detected.</summary>
Bad_SensorFailure = 16,
/// <summary>Bad last known value (communication lost, value stale).</summary>
Bad_LastKnownValue = 20,
/// <summary>Bad communication failure.</summary>
Bad_CommFailure = 24,
/// <summary>Bad item is out of service.</summary>
Bad_OutOfService = 28,
/// <summary>Uncertain non-specific.</summary>
Uncertain = 64,
/// <summary>Uncertain non-specific, low limited.</summary>
Uncertain_LowLimited = 65,
/// <summary>Uncertain non-specific, high limited.</summary>
Uncertain_HighLimited = 66,
/// <summary>Uncertain non-specific, constant.</summary>
Uncertain_Constant = 67,
/// <summary>Uncertain last usable value.</summary>
Uncertain_LastUsable = 68,
/// <summary>Uncertain last usable value, low limited.</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>Uncertain last usable value, high limited.</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>Uncertain last usable value, constant.</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>Uncertain sensor not accurate.</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>Uncertain sensor not accurate, low limited.</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>Uncertain sensor not accurate, high limited.</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>Uncertain sensor not accurate, constant.</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>Uncertain engineering units exceeded.</summary>
Uncertain_EuExceeded = 84,
/// <summary>Uncertain engineering units exceeded, low limited.</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>Uncertain engineering units exceeded, high limited.</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>Uncertain engineering units exceeded, constant.</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>Uncertain sub-normal operating conditions.</summary>
Uncertain_SubNormal = 88,
/// <summary>Uncertain sub-normal, low limited.</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>Uncertain sub-normal, high limited.</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>Uncertain sub-normal, constant.</summary>
Uncertain_SubNormal_C = 91,
/// <summary>Good non-specific.</summary>
Good = 192,
/// <summary>Good low limited.</summary>
Good_LowLimited = 193,
/// <summary>Good high limited.</summary>
Good_HighLimited = 194,
/// <summary>Good constant.</summary>
Good_Constant = 195,
/// <summary>Good local override active.</summary>
Good_LocalOverride = 216,
/// <summary>Good local override active, low limited.</summary>
Good_LocalOverride_LL = 217,
/// <summary>Good local override active, high limited.</summary>
Good_LocalOverride_HL = 218,
/// <summary>Good local override active, constant.</summary>
Good_LocalOverride_C = 219
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
public static class QualityExtensions
{
public static bool IsGood(this Quality q) => (byte)q >= 128;
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
public static bool IsBad(this Quality q) => (byte)q < 64;
}

View File

@@ -0,0 +1,444 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
// ────────────────────────────────────────────────────────────────
// Service contract
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Code-first gRPC service contract for SCADA operations.
/// </summary>
[ServiceContract(Name = "scada.ScadaService")]
public interface IScadaService
{
/// <summary>Establishes a connection with the SCADA service.</summary>
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
/// <summary>Terminates a SCADA service connection.</summary>
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
/// <summary>Retrieves the current state of a SCADA connection.</summary>
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
/// <summary>Reads a single tag value from the SCADA system.</summary>
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
/// <summary>Writes multiple values to tags in the SCADA system in a batch operation.</summary>
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
/// <summary>Validates an API key for authentication.</summary>
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
}
// ────────────────────────────────────────────────────────────────
// VTQ message
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Value-Timestamp-Quality message transmitted over gRPC.
/// All values are string-encoded; timestamps are UTC ticks.
/// </summary>
[DataContract]
public class VtqMessage
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value encoded as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
[DataMember(Order = 3)]
public long TimestampUtcTicks { get; set; }
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
[DataMember(Order = 4)]
public string Quality { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Connect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to establish a session with the proxy server.</summary>
[DataContract]
public class ConnectRequest
{
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
[DataMember(Order = 1)]
public string ClientId { get; set; } = string.Empty;
/// <summary>API key for authentication (empty if none required).</summary>
[DataMember(Order = 2)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from a Connect call.</summary>
[DataContract]
public class ConnectResponse
{
/// <summary>Whether the connection was established successfully.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Session ID (32-char hex GUID). Only valid when <see cref="Success"/> is <c>true</c>.</summary>
[DataMember(Order = 3)]
public string SessionId { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Disconnect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to terminate a session.</summary>
[DataContract]
public class DisconnectRequest
{
/// <summary>Active session ID to disconnect.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response from a Disconnect call.</summary>
[DataContract]
public class DisconnectResponse
{
/// <summary>Whether the disconnect succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// GetConnectionState
// ────────────────────────────────────────────────────────────────
/// <summary>Request to query connection state for a session.</summary>
[DataContract]
public class GetConnectionStateRequest
{
/// <summary>Session ID to query.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response with connection state information.</summary>
[DataContract]
public class GetConnectionStateResponse
{
/// <summary>Whether the session is currently connected.</summary>
[DataMember(Order = 1)]
public bool IsConnected { get; set; }
/// <summary>Client identifier for this session.</summary>
[DataMember(Order = 2)]
public string ClientId { get; set; } = string.Empty;
/// <summary>UTC ticks when the connection was established.</summary>
[DataMember(Order = 3)]
public long ConnectedSinceUtcTicks { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Read
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read a single tag.</summary>
[DataContract]
public class ReadRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to read.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Read call.</summary>
[DataContract]
public class ReadResponse
{
/// <summary>Whether the read succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message if the read failed.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>The value-timestamp-quality result.</summary>
[DataMember(Order = 3)]
public VtqMessage? Vtq { get; set; }
}
// ────────────────────────────────────────────────────────────────
// ReadBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read multiple tags in a single round-trip.</summary>
[DataContract]
public class ReadBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to read.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
}
/// <summary>Response from a batch Read call.</summary>
[DataContract]
public class ReadBatchResponse
{
/// <summary>False if any tag read failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>VTQ results in the same order as the request tags.</summary>
[DataMember(Order = 3)]
public List<VtqMessage> Vtqs { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// Write
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write a single tag value.</summary>
[DataContract]
public class WriteRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to write.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string (parsed server-side).</summary>
[DataMember(Order = 3)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Write call.</summary>
[DataContract]
public class WriteResponse
{
/// <summary>Whether the write succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteItem / WriteResult
// ────────────────────────────────────────────────────────────────
/// <summary>A single tag-value pair for batch write operations.</summary>
[DataContract]
public class WriteItem
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Per-item result from a batch write operation.</summary>
[DataContract]
public class WriteResult
{
/// <summary>Tag address that was written.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Whether the individual write succeeded.</summary>
[DataMember(Order = 2)]
public bool Success { get; set; }
/// <summary>Error message for this item, if any.</summary>
[DataMember(Order = 3)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
[DataContract]
public class WriteBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
}
/// <summary>Response from a batch Write call.</summary>
[DataContract]
public class WriteBatchResponse
{
/// <summary>Overall success — false if any item failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> Results { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// WriteBatchAndWait
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Request to write multiple tag values then poll a flag tag
/// until it matches an expected value or the timeout expires.
/// </summary>
[DataContract]
public class WriteBatchAndWaitRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
/// <summary>Tag to poll after writes complete.</summary>
[DataMember(Order = 3)]
public string FlagTag { get; set; } = string.Empty;
/// <summary>Expected value for the flag tag (string comparison).</summary>
[DataMember(Order = 4)]
public string FlagValue { get; set; } = string.Empty;
/// <summary>Timeout in milliseconds (default 5000 if &lt;= 0).</summary>
[DataMember(Order = 5)]
public int TimeoutMs { get; set; }
/// <summary>Poll interval in milliseconds (default 100 if &lt;= 0).</summary>
[DataMember(Order = 6)]
public int PollIntervalMs { get; set; }
}
/// <summary>Response from a WriteBatchAndWait call.</summary>
[DataContract]
public class WriteBatchAndWaitResponse
{
/// <summary>Overall operation success.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> WriteResults { get; set; } = [];
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
[DataMember(Order = 4)]
public bool FlagReached { get; set; }
/// <summary>Total elapsed time in milliseconds.</summary>
[DataMember(Order = 5)]
public int ElapsedMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Subscribe
// ────────────────────────────────────────────────────────────────
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
[DataContract]
public class SubscribeRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to monitor.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
/// <summary>Backend sampling interval in milliseconds.</summary>
[DataMember(Order = 3)]
public int SamplingMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// CheckApiKey
// ────────────────────────────────────────────────────────────────
/// <summary>Request to validate an API key without creating a session.</summary>
[DataContract]
public class CheckApiKeyRequest
{
/// <summary>API key to validate.</summary>
[DataMember(Order = 1)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from an API key validation check.</summary>
[DataContract]
public class CheckApiKeyResponse
{
/// <summary>Whether the API key is valid.</summary>
[DataMember(Order = 1)]
public bool IsValid { get; set; }
/// <summary>Validation message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
/// <param name="Value">The value.</param>
/// <param name="Timestamp">The timestamp when the value was read.</param>
/// <param name="Quality">The quality of the value.</param>
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
{
/// <summary>Creates a new VTQ with the specified value and quality, using the current UTC timestamp.</summary>
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>Creates a new VTQ with the specified value, timestamp, and quality.</summary>
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>Creates a Good-quality VTQ with the current UTC time.</summary>
public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>Creates a Bad-quality VTQ with the current UTC time.</summary>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>Creates an Uncertain-quality VTQ with the current UTC time.</summary>
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Interface for LmxProxy client operations
/// </summary>
public interface ILmxProxyClient : IDisposable, IAsyncDisposable
{
/// <summary>
/// Gets or sets the default timeout for operations
/// </summary>
TimeSpan DefaultTimeout { get; set; }
/// <summary>
/// Connects to the LmxProxy service
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Disconnects from the LmxProxy service
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Checks if the client is connected to the service
/// </summary>
Task<bool> IsConnectedAsync();
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
/// <summary>
/// Reads multiple tag values in a single batch
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple tag values in a single batch
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to tag updates
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ISubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> onUpdate, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
Dictionary<string, object> GetMetrics();
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Factory interface for creating LmxProxyClient instances
/// </summary>
public interface ILmxProxyClientFactory
{
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient();
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(string configurationName);
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
/// <summary>
/// Default implementation of ILmxProxyClientFactory
/// </summary>
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the LmxProxyClientFactory
/// </summary>
/// <param name="configuration">Application configuration</param>
public LmxProxyClientFactory(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient()
{
return CreateClient("LmxProxy");
}
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(string configurationName)
{
IConfigurationSection section = _configuration.GetSection(configurationName);
if (!section.GetChildren().Any() && section.Value == null)
{
throw new InvalidOperationException($"Configuration section '{configurationName}' not found");
}
var builder = new LmxProxyClientBuilder();
// Configure from appsettings
string? host = section["Host"];
if (!string.IsNullOrEmpty(host))
{
builder.WithHost(host);
}
if (int.TryParse(section["Port"], out int port))
{
builder.WithPort(port);
}
string? apiKey = section["ApiKey"];
if (!string.IsNullOrEmpty(apiKey))
{
builder.WithApiKey(apiKey);
}
if (TimeSpan.TryParse(section["Timeout"], out TimeSpan timeout))
{
builder.WithTimeout(timeout);
}
// Retry configuration
IConfigurationSection? retrySection = section.GetSection("Retry");
if (retrySection != null && (retrySection.GetChildren().Any() || retrySection.Value != null))
{
if (int.TryParse(retrySection["MaxAttempts"], out int maxAttempts) &&
TimeSpan.TryParse(retrySection["Delay"], out TimeSpan retryDelay))
{
builder.WithRetryPolicy(maxAttempts, retryDelay);
}
}
// SSL configuration
bool useSsl = section.GetValue<bool>("UseSsl");
if (useSsl)
{
string? certificatePath = section["CertificatePath"];
builder.WithSslCredentials(certificatePath);
}
// Metrics configuration
if (section.GetValue<bool>("EnableMetrics"))
{
builder.WithMetrics();
}
// Correlation ID configuration
string? correlationHeader = section["CorrelationIdHeader"];
if (!string.IsNullOrEmpty(correlationHeader))
{
builder.WithCorrelationIdHeader(correlationHeader);
}
// Logger is optional - don't set a default one
return builder.Build();
}
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
{
ArgumentNullException.ThrowIfNull(builderAction);
var builder = new LmxProxyClientBuilder();
builderAction(builder);
// Logger is optional - caller can set it via builderAction if needed
return builder.Build();
}
}
}

View File

@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// API key information returned from CheckApiKey
/// </summary>
public class ApiKeyInfo
{
/// <summary>
/// Whether the API key is valid
/// </summary>
public bool IsValid { get; }
/// <summary>
/// The role assigned to the API key
/// </summary>
public string Role { get; }
/// <summary>
/// Description of the API key
/// </summary>
public string Description { get; }
/// <summary>
/// Initializes a new instance of the ApiKeyInfo class
/// </summary>
/// <param name="isValid">Whether the API key is valid</param>
/// <param name="role">The role assigned to the API key</param>
/// <param name="description">Description of the API key</param>
public ApiKeyInfo(bool isValid, string role, string description)
{
IsValid = isValid;
Role = role ?? string.Empty;
Description = description ?? string.Empty;
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Metrics collection for client operations
/// </summary>
internal class ClientMetrics
{
private readonly ConcurrentDictionary<string, long> _operationCounts = new();
private readonly ConcurrentDictionary<string, long> _errorCounts = new();
private readonly ConcurrentDictionary<string, List<long>> _latencies = new();
private readonly object _latencyLock = new();
/// <summary>
/// Increments the operation count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementOperationCount(string operation)
{
_operationCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Increments the error count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementErrorCount(string operation)
{
_errorCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Records latency for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
/// <param name="milliseconds">The latency in milliseconds.</param>
public void RecordLatency(string operation, long milliseconds)
{
lock (_latencyLock)
{
if (!_latencies.ContainsKey(operation))
{
_latencies[operation] = [];
}
_latencies[operation].Add(milliseconds);
// Keep only last 1000 entries to prevent memory growth
if (_latencies[operation].Count > 1000)
{
_latencies[operation].RemoveAt(0);
}
}
}
/// <summary>
/// Gets a snapshot of current metrics.
/// </summary>
/// <returns>A dictionary containing metric data.</returns>
public Dictionary<string, object> GetSnapshot()
{
var snapshot = new Dictionary<string, object>();
foreach (KeyValuePair<string, long> kvp in _operationCounts)
{
snapshot[$"{kvp.Key}_count"] = kvp.Value;
}
foreach (KeyValuePair<string, long> kvp in _errorCounts)
{
snapshot[$"{kvp.Key}_errors"] = kvp.Value;
}
lock (_latencyLock)
{
foreach (KeyValuePair<string, List<long>> kvp in _latencies)
{
if (kvp.Value.Any())
{
snapshot[$"{kvp.Key}_avg_latency_ms"] = kvp.Value.Average();
snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(kvp.Value, 95);
snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(kvp.Value, 99);
}
}
}
return snapshot;
}
private double GetPercentile(List<long> values, int percentile)
{
var sorted = values.OrderBy(x => x).ToList();
int index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
return sorted[Math.Max(0, index)];
}
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
public partial class LmxProxyClient
{
private class CodeFirstSubscription : ISubscription
{
private readonly IScadaService _client;
private readonly string _sessionId;
private readonly List<string> _tags;
private readonly Action<string, Vtq> _onUpdate;
private readonly ILogger<LmxProxyClient> _logger;
private readonly Action<ISubscription>? _onDispose;
private readonly CancellationTokenSource _cts = new();
private Task? _processingTask;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the CodeFirstSubscription class.
/// </summary>
/// <param name="client">The gRPC ScadaService client.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="tags">The list of tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="logger">Logger for diagnostic information.</param>
/// <param name="onDispose">Optional callback invoked when the subscription is disposed.</param>
public CodeFirstSubscription(
IScadaService client,
string sessionId,
List<string> tags,
Action<string, Vtq> onUpdate,
ILogger<LmxProxyClient> logger,
Action<ISubscription>? onDispose = null)
{
_client = client;
_sessionId = sessionId;
_tags = tags;
_onUpdate = onUpdate;
_logger = logger;
_onDispose = onDispose;
}
/// <summary>
/// Starts the subscription asynchronously and begins processing tag value updates.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the subscription processing has started.</returns>
public Task StartAsync(CancellationToken cancellationToken = default)
{
_processingTask = ProcessUpdatesAsync(cancellationToken);
return Task.CompletedTask;
}
private async Task ProcessUpdatesAsync(CancellationToken cancellationToken)
{
try
{
var request = new SubscribeRequest
{
SessionId = _sessionId,
Tags = _tags,
SamplingMs = 1000
};
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
await foreach (VtqMessage vtq in _client.SubscribeAsync(request, linkedCts.Token))
{
try
{
Vtq convertedVtq = ConvertToVtq(vtq.Tag, vtq);
_onUpdate(vtq.Tag, convertedVtq);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtq.Tag);
}
}
}
catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested || cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Subscription cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in subscription processing");
try { await _cts.CancelAsync(); } catch { /* ignore */ }
}
finally
{
if (!_disposed)
{
_disposed = true;
_onDispose?.Invoke(this);
}
}
}
/// <summary>
/// Asynchronously disposes the subscription and stops processing tag updates.
/// </summary>
/// <returns>A task representing the asynchronous disposal operation.</returns>
public async Task DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _cts.CancelAsync();
try
{
if (_processingTask != null)
{
await _processingTask;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
finally
{
_cts.Dispose();
_onDispose?.Invoke(this);
}
}
/// <summary>
/// Synchronously disposes the subscription and stops processing tag updates.
/// </summary>
public void Dispose()
{
if (_disposed) return;
try
{
Task task = DisposeAsync();
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
_logger.LogWarning("Subscription disposal timed out");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during synchronous disposal");
}
}
}
}
}

View File

@@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ProtoBuf.Grpc.Client;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Security;
namespace ZB.MOM.WW.LmxProxy.Client
{
public partial class LmxProxyClient
{
/// <summary>
/// Connects to the LmxProxy service and establishes a session
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
GrpcChannel? provisionalChannel = null;
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(LmxProxyClient));
}
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
{
_logger.LogDebug("LmxProxyClient already connected to {Host}:{Port} with session {SessionId}",
_host, _port, _sessionId);
return;
}
string securityMode = _tlsConfiguration?.UseTls == true ? "TLS/SSL" : "INSECURE";
_logger.LogInformation("Creating new {SecurityMode} connection to LmxProxy at {Host}:{Port}",
securityMode, _host, _port);
Uri endpoint = BuildEndpointUri();
provisionalChannel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger);
// Create code-first gRPC client
IScadaService provisionalClient = provisionalChannel.CreateGrpcService<IScadaService>();
// Establish session with the server
var connectRequest = new ConnectRequest
{
ClientId = $"ScadaBridge-{Guid.NewGuid():N}",
ApiKey = _apiKey ?? string.Empty
};
ConnectResponse connectResponse = await provisionalClient.ConnectAsync(connectRequest);
if (!connectResponse.Success)
{
provisionalChannel.Dispose();
throw new InvalidOperationException($"Failed to establish session: {connectResponse.Message}");
}
// Dispose any existing channel before replacing it
_channel?.Dispose();
_channel = provisionalChannel;
_client = provisionalClient;
_sessionId = connectResponse.SessionId;
_isConnected = true;
provisionalChannel = null;
StartKeepAlive();
_logger.LogInformation("Successfully connected to LmxProxy with session {SessionId}", _sessionId);
}
catch (Exception ex)
{
_isConnected = false;
_client = null;
_sessionId = string.Empty;
_logger.LogError(ex, "Failed to connect to LmxProxy");
throw;
}
finally
{
provisionalChannel?.Dispose();
_connectionLock.Release();
}
}
private void StartKeepAlive()
{
StopKeepAlive();
_keepAliveTimer = new Timer(async _ =>
{
try
{
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
{
// Send a lightweight ping to keep session alive
var request = new GetConnectionStateRequest { SessionId = _sessionId };
await _client.GetConnectionStateAsync(request);
_logger.LogDebug("Keep-alive ping sent successfully for session {SessionId}", _sessionId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Keep-alive ping failed");
StopKeepAlive();
await MarkDisconnectedAsync(ex).ConfigureAwait(false);
}
}, null, _keepAliveInterval, _keepAliveInterval);
}
private void StopKeepAlive()
{
_keepAliveTimer?.Dispose();
_keepAliveTimer = null;
}
/// <summary>
/// Disconnects from the LmxProxy service
/// </summary>
public async Task DisconnectAsync()
{
await _connectionLock.WaitAsync();
try
{
StopKeepAlive();
if (_client != null && !string.IsNullOrEmpty(_sessionId))
{
try
{
var request = new DisconnectRequest { SessionId = _sessionId };
await _client.DisconnectAsync(request);
_logger.LogInformation("Session {SessionId} disconnected", _sessionId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during disconnect");
}
}
_client = null;
_sessionId = string.Empty;
_isConnected = false;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
}
/// <summary>
/// Connects the LmxProxy to MxAccess (legacy method - session now established in ConnectAsync)
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<(bool Success, string? ErrorMessage)> ConnectToMxAccessAsync(CancellationToken cancellationToken = default)
{
// Session is now established in ConnectAsync
if (IsConnected)
return Task.FromResult((true, (string?)null));
return Task.FromResult<(bool Success, string? ErrorMessage)>((false, "Not connected. Call ConnectAsync first."));
}
/// <summary>
/// Disconnects the LmxProxy from MxAccess (legacy method)
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<(bool Success, string? ErrorMessage)> DisconnectFromMxAccessAsync(CancellationToken cancellationToken = default)
{
try
{
await DisconnectAsync();
return (true, null);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
/// <summary>
/// Gets the connection state of the LmxProxy
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<(bool IsConnected, string? ClientId)> GetConnectionStateAsync(CancellationToken cancellationToken = default)
{
EnsureConnected();
var request = new GetConnectionStateRequest { SessionId = _sessionId };
GetConnectionStateResponse response = await _client!.GetConnectionStateAsync(request);
return (response.IsConnected, response.ClientId);
}
/// <summary>
/// Builds the gRPC endpoint URI (http/https) based on TLS configuration.
/// </summary>
private Uri BuildEndpointUri()
{
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
return new UriBuilder
{
Scheme = scheme,
Host = _host,
Port = _port
}.Uri;
}
private async Task MarkDisconnectedAsync(Exception? ex = null)
{
if (_disposed)
return;
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
_isConnected = false;
_client = null;
_sessionId = string.Empty;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
List<ISubscription> subsToDispose;
lock (_subscriptionLock)
{
subsToDispose = new List<ISubscription>(_activeSubscriptions);
_activeSubscriptions.Clear();
}
foreach (ISubscription sub in subsToDispose)
{
try
{
await sub.DisposeAsync().ConfigureAwait(false);
}
catch (Exception disposeEx)
{
_logger.LogWarning(disposeEx, "Error disposing subscription after disconnect");
}
}
if (ex != null)
{
_logger.LogWarning(ex, "Connection marked disconnected due to keep-alive failure");
}
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Represents a subscription to tag value changes
/// </summary>
public interface ISubscription : IDisposable
{
/// <summary>
/// Disposes the subscription asynchronously
/// </summary>
Task DisposeAsync();
}
}

View File

@@ -0,0 +1,573 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Polly;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Security;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Client for communicating with the LmxProxy gRPC service using protobuf-net.Grpc code-first
/// </summary>
public partial class LmxProxyClient : ILmxProxyClient
{
private static readonly string Http2InsecureSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
private readonly ILogger<LmxProxyClient> _logger;
private readonly string _host;
private readonly int _port;
private readonly string? _apiKey;
private GrpcChannel? _channel;
private IScadaService? _client;
private string _sessionId = string.Empty;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly List<ISubscription> _activeSubscriptions = [];
private readonly Lock _subscriptionLock = new();
private bool _disposed;
private bool _isConnected;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private ClientConfiguration? _configuration;
private IAsyncPolicy? _retryPolicy;
private readonly ClientMetrics _metrics = new();
private Timer? _keepAliveTimer;
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
private readonly ClientTlsConfiguration? _tlsConfiguration;
static LmxProxyClient()
{
AppContext.SetSwitch(Http2InsecureSwitch, true);
}
/// <summary>
/// Gets or sets the default timeout for operations
/// </summary>
public TimeSpan DefaultTimeout
{
get => _defaultTimeout;
set
{
if (value <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive");
if (value > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(value), "Timeout cannot exceed 10 minutes");
_defaultTimeout = value;
}
}
/// <summary>
/// Initializes a new instance of the LmxProxyClient
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey = null, ILogger<LmxProxyClient>? logger = null)
: this(host, port, apiKey, null, logger)
{
}
/// <summary>
/// Creates a new instance of the LmxProxyClient with TLS configuration
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="tlsConfiguration">TLS configuration for secure connections</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger<LmxProxyClient>? logger = null)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_host = host;
_port = port;
_apiKey = apiKey;
_tlsConfiguration = tlsConfiguration;
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
}
/// <summary>
/// Gets whether the client is connected to the service
/// </summary>
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
/// <summary>
/// Asynchronously checks if the client is connected with proper synchronization
/// </summary>
public async Task<bool> IsConnectedAsync()
{
await _connectionLock.WaitAsync();
try
{
return !_disposed && _client != null && _isConnected && !string.IsNullOrEmpty(_sessionId);
}
finally
{
_connectionLock.Release();
}
}
/// <summary>
/// Sets the builder configuration (internal use)
/// </summary>
/// <param name="configuration">The client configuration.</param>
internal void SetBuilderConfiguration(ClientConfiguration configuration)
{
_configuration = configuration;
// Setup retry policy if configured
if (configuration.MaxRetryAttempts > 0)
{
_retryPolicy = Policy
.Handle<Exception>(IsTransientError)
.WaitAndRetryAsync(
configuration.MaxRetryAttempts,
retryAttempt => configuration.RetryDelay * Math.Pow(2, retryAttempt - 1),
onRetry: (exception, timeSpan, retryCount, context) =>
{
object? correlationId = context.GetValueOrDefault("CorrelationId", "N/A");
_logger.LogWarning(exception,
"Retry {RetryCount} after {Delay}ms. CorrelationId: {CorrelationId}",
retryCount, timeSpan.TotalMilliseconds, correlationId);
});
}
}
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
EnsureConnected();
string correlationId = GenerateCorrelationId();
var stopwatch = Stopwatch.StartNew();
try
{
_metrics.IncrementOperationCount("Read");
var request = new ReadRequest
{
SessionId = _sessionId,
Tag = address
};
ReadResponse response = await ExecuteWithRetryAsync(async () =>
await _client!.ReadAsync(request),
correlationId);
if (!response.Success)
{
_metrics.IncrementErrorCount("Read");
throw new InvalidOperationException($"Read failed for tag '{address}': {response.Message}. CorrelationId: {correlationId}");
}
_metrics.RecordLatency("Read", stopwatch.ElapsedMilliseconds);
return ConvertToVtq(address, response.Vtq);
}
catch (Exception ex)
{
_metrics.IncrementErrorCount("Read");
_logger.LogError(ex, "Read operation failed for tag: {Tag}, CorrelationId: {CorrelationId}",
address, correlationId);
throw;
}
}
/// <summary>
/// Reads multiple tag values
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(addresses);
var addressList = addresses.ToList();
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
EnsureConnected();
var request = new ReadBatchRequest
{
SessionId = _sessionId,
Tags = addressList
};
ReadBatchResponse response = await _client!.ReadBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"ReadBatch failed: {response.Message}");
var results = new Dictionary<string, Vtq>();
foreach (VtqMessage vtq in response.Vtqs)
{
results[vtq.Tag] = ConvertToVtq(vtq.Tag, vtq);
}
return results;
}
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
ArgumentNullException.ThrowIfNull(value);
EnsureConnected();
var request = new WriteRequest
{
SessionId = _sessionId,
Tag = address,
Value = ConvertToString(value)
};
WriteResponse response = await _client!.WriteAsync(request);
if (!response.Success)
throw new InvalidOperationException($"Write failed: {response.Message}");
}
/// <summary>
/// Writes multiple tag values
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList()
};
WriteBatchResponse response = await _client!.WriteBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
}
/// <summary>
/// Writes values and waits for a condition to be met
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="flagAddress">The flag address to write.</param>
/// <param name="flagValue">The flag value to write.</param>
/// <param name="responseAddress">The response address to monitor.</param>
/// <param name="responseValue">The expected response value.</param>
/// <param name="timeoutSeconds">Timeout in seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
int timeoutSeconds = 30,
CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchAndWaitRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList(),
FlagTag = flagAddress,
FlagValue = ConvertToString(flagValue),
TimeoutMs = timeoutSeconds * 1000,
PollIntervalMs = 100
};
WriteBatchAndWaitResponse response = await _client!.WriteBatchAndWaitAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatchAndWait failed: {response.Message}");
return response.FlagReached;
}
/// <summary>
/// Checks the validity and permissions of the current API key
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ApiKeyInfo> CheckApiKeyAsync(CancellationToken cancellationToken = default)
{
EnsureConnected();
var request = new CheckApiKeyRequest { ApiKey = _apiKey ?? string.Empty };
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
return new ApiKeyInfo(
response.IsValid,
"ReadWrite", // Code-first contract doesn't return role
response.Message);
}
/// <summary>
/// Subscribes to tag value changes
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ISubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> onUpdate,
CancellationToken cancellationToken = default)
{
List<string> addressList = addresses?.ToList() ?? throw new ArgumentNullException(nameof(addresses));
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
ArgumentNullException.ThrowIfNull(onUpdate);
EnsureConnected();
var subscription = new CodeFirstSubscription(_client!, _sessionId, addressList, onUpdate, _logger, RemoveSubscription);
// Track the subscription
lock (_subscriptionLock)
{
_activeSubscriptions.Add(subscription);
}
// Start processing updates
Task startTask = subscription.StartAsync(cancellationToken);
// Log any startup errors but don't throw
startTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
_logger.LogError(t.Exception, "Subscription startup failed");
}
}, TaskContinuationOptions.OnlyOnFaulted);
return Task.FromResult<ISubscription>(subscription);
}
private void EnsureConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(LmxProxyClient));
if (_client == null || !_isConnected || string.IsNullOrEmpty(_sessionId))
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
}
private static Vtq ConvertToVtq(string tag, VtqMessage? vtqMessage)
{
if (vtqMessage == null)
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Parse the string value
object? value = vtqMessage.Value;
if (!string.IsNullOrEmpty(vtqMessage.Value))
{
// Try to parse as numeric types
if (double.TryParse(vtqMessage.Value, out double doubleVal))
value = doubleVal;
else if (bool.TryParse(vtqMessage.Value, out bool boolVal))
value = boolVal;
else
value = vtqMessage.Value;
}
var timestamp = new DateTime(vtqMessage.TimestampUtcTicks, DateTimeKind.Utc);
Quality quality = vtqMessage.Quality?.ToUpperInvariant() switch
{
"GOOD" => Quality.Good,
"UNCERTAIN" => Quality.Uncertain,
_ => Quality.Bad
};
return new Vtq(value, timestamp, quality);
}
private static string ConvertToString(object value)
{
if (value == null)
return string.Empty;
return value switch
{
DateTime dt => dt.ToUniversalTime().ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
bool b => b.ToString().ToLowerInvariant(),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Removes a subscription from the active tracking list
/// </summary>
private void RemoveSubscription(ISubscription subscription)
{
lock (_subscriptionLock)
{
_activeSubscriptions.Remove(subscription);
}
}
/// <summary>
/// Disposes of the client and closes the connection
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().AsTask().GetAwaiter().GetResult();
GC.SuppressFinalize(this);
}
/// <summary>
/// Asynchronously disposes of the client and closes the connection
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
await DisposeCoreAsync().ConfigureAwait(false);
_connectionLock.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected disposal implementation
/// </summary>
/// <param name="disposing">True if disposing managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing || _disposed)
return;
_disposed = true;
DisposeCoreAsync().GetAwaiter().GetResult();
_connectionLock.Dispose();
}
private async Task DisposeCoreAsync()
{
StopKeepAlive();
List<ISubscription> subscriptionsToDispose;
lock (_subscriptionLock)
{
subscriptionsToDispose = new List<ISubscription>(_activeSubscriptions);
_activeSubscriptions.Clear();
}
foreach (ISubscription subscription in subscriptionsToDispose)
{
try
{
await subscription.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
}
// Disconnect session
if (_client != null && !string.IsNullOrEmpty(_sessionId))
{
try
{
var request = new DisconnectRequest { SessionId = _sessionId };
await _client.DisconnectAsync(request);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error during disconnect on dispose");
}
}
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
_client = null;
_sessionId = string.Empty;
_isConnected = false;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
}
private string GenerateCorrelationId()
{
return Guid.NewGuid().ToString("N");
}
private bool IsTransientError(Exception ex)
{
// Check for transient gRPC errors
return ex.Message.Contains("Unavailable") ||
ex.Message.Contains("DeadlineExceeded") ||
ex.Message.Contains("ResourceExhausted") ||
ex.Message.Contains("Aborted");
}
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, string correlationId)
{
if (_retryPolicy != null)
{
var context = new Context { ["CorrelationId"] = correlationId };
return await _retryPolicy.ExecuteAsync(async (_) => await operation(), context);
}
return await operation();
}
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
}
}

View File

@@ -0,0 +1,241 @@
using System;
using System.IO;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Builder for creating configured instances of LmxProxyClient
/// </summary>
public class LmxProxyClientBuilder
{
private string? _host;
private int _port = 5050;
private string? _apiKey;
private ILogger<LmxProxyClient>? _logger;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private int _maxRetryAttempts = 3;
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
private bool _enableMetrics;
private string? _correlationIdHeader;
private ClientTlsConfiguration? _tlsConfiguration;
/// <summary>
/// Sets the host address for the LmxProxy service
/// </summary>
/// <param name="host">The host address</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
_host = host;
return this;
}
/// <summary>
/// Sets the port for the LmxProxy service
/// </summary>
/// <param name="port">The port number</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithPort(int port)
{
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_port = port;
return this;
}
/// <summary>
/// Sets the API key for authentication
/// </summary>
/// <param name="apiKey">The API key</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithApiKey(string apiKey)
{
_apiKey = apiKey;
return this;
}
/// <summary>
/// Sets the logger instance
/// </summary>
/// <param name="logger">The logger</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
return this;
}
/// <summary>
/// Sets the default timeout for operations
/// </summary>
/// <param name="timeout">The timeout duration</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive");
if (timeout > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout cannot exceed 10 minutes");
_defaultTimeout = timeout;
return this;
}
/// <summary>
/// Enables SSL/TLS with the specified certificate
/// </summary>
/// <param name="certificatePath">Path to the certificate file</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath = null)
{
_tlsConfiguration ??= new ClientTlsConfiguration();
_tlsConfiguration.UseTls = true;
_tlsConfiguration.ServerCaCertificatePath = string.IsNullOrWhiteSpace(certificatePath) ? null : certificatePath;
return this;
}
/// <summary>
/// Applies a full TLS configuration to the client.
/// </summary>
/// <param name="configuration">The TLS configuration to apply.</param>
/// <returns>The builder instance for method chaining.</returns>
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration configuration)
{
_tlsConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration));
return this;
}
/// <summary>
/// Sets the retry configuration
/// </summary>
/// <param name="maxAttempts">Maximum number of retry attempts</param>
/// <param name="retryDelay">Delay between retries</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
{
if (maxAttempts <= 0)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max attempts must be positive");
if (retryDelay <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be positive");
_maxRetryAttempts = maxAttempts;
_retryDelay = retryDelay;
return this;
}
/// <summary>
/// Enables metrics collection
/// </summary>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithMetrics()
{
_enableMetrics = true;
return this;
}
/// <summary>
/// Sets the correlation ID header name for request tracing
/// </summary>
/// <param name="headerName">The header name for correlation ID</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
{
if (string.IsNullOrEmpty(headerName))
throw new ArgumentException("Header name cannot be null or empty", nameof(headerName));
_correlationIdHeader = headerName;
return this;
}
/// <summary>
/// Builds the configured LmxProxyClient instance
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient Build()
{
if (string.IsNullOrWhiteSpace(_host))
throw new InvalidOperationException("Host must be specified");
ValidateTlsConfiguration();
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
{
DefaultTimeout = _defaultTimeout
};
// Store additional configuration for future use
client.SetBuilderConfiguration(new ClientConfiguration
{
MaxRetryAttempts = _maxRetryAttempts,
RetryDelay = _retryDelay,
EnableMetrics = _enableMetrics,
CorrelationIdHeader = _correlationIdHeader
});
return client;
}
private void ValidateTlsConfiguration()
{
if (_tlsConfiguration?.UseTls != true)
{
return;
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ServerCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
{
throw new FileNotFoundException(
$"Certificate file not found: {_tlsConfiguration.ServerCaCertificatePath}",
_tlsConfiguration.ServerCaCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCertificatePath))
{
throw new FileNotFoundException(
$"Client certificate file not found: {_tlsConfiguration.ClientCertificatePath}",
_tlsConfiguration.ClientCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientKeyPath) &&
!File.Exists(_tlsConfiguration.ClientKeyPath))
{
throw new FileNotFoundException(
$"Client key file not found: {_tlsConfiguration.ClientKeyPath}",
_tlsConfiguration.ClientKeyPath);
}
}
}
/// <summary>
/// Internal configuration class for storing builder settings
/// </summary>
internal class ClientConfiguration
{
/// <summary>
/// Gets or sets the maximum number of retry attempts.
/// </summary>
public int MaxRetryAttempts { get; set; }
/// <summary>
/// Gets or sets the retry delay.
/// </summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether metrics are enabled.
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name.
/// </summary>
public string? CorrelationIdHeader { get; set; }
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
// Expose internal members to test assembly
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]

View File

@@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client.Security;
internal static class GrpcChannelFactory
{
private const string Http2UnencryptedSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
static GrpcChannelFactory()
{
AppContext.SetSwitch(Http2UnencryptedSwitch, true);
}
/// <summary>
/// Creates a gRPC channel with optional TLS configuration.
/// </summary>
/// <param name="address">The server address.</param>
/// <param name="tlsConfiguration">Optional TLS configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>A configured gRPC channel.</returns>
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var options = new GrpcChannelOptions
{
HttpHandler = CreateHttpHandler(tlsConfiguration, logger)
};
return GrpcChannel.ForAddress(address, options);
}
private static HttpMessageHandler CreateHttpHandler(ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
EnableMultipleHttp2Connections = true
};
if (tlsConfiguration?.UseTls == true)
{
ConfigureTls(handler, tlsConfiguration, logger);
}
return handler;
}
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tlsConfiguration, ILogger logger)
{
SslClientAuthenticationOptions sslOptions = handler.SslOptions;
sslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
sslOptions.TargetHost = tlsConfiguration.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ClientCertificatePath) &&
!string.IsNullOrWhiteSpace(tlsConfiguration.ClientKeyPath))
{
try
{
var clientCertificate = X509Certificate2.CreateFromPemFile(
tlsConfiguration.ClientCertificatePath,
tlsConfiguration.ClientKeyPath);
clientCertificate = new X509Certificate2(clientCertificate.Export(X509ContentType.Pfx));
sslOptions.ClientCertificates ??= new X509CertificateCollection();
sslOptions.ClientCertificates.Add(clientCertificate);
logger.LogInformation("Configured client certificate for mutual TLS ({CertificatePath})", tlsConfiguration.ClientCertificatePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load client certificate from {CertificatePath}", tlsConfiguration.ClientCertificatePath);
}
}
sslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificate(tlsConfiguration, logger, certificate, chain, sslPolicyErrors);
}
private static bool ValidateServerCertificate(
ClientTlsConfiguration tlsConfiguration,
ILogger logger,
X509Certificate? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{
if (tlsConfiguration.IgnoreAllCertificateErrors)
{
logger.LogWarning("SECURITY WARNING: Ignoring all certificate validation errors for LmxProxy gRPC connection.");
return true;
}
if (certificate is null)
{
logger.LogWarning("Server certificate was null.");
return false;
}
if (!tlsConfiguration.ValidateServerCertificate)
{
logger.LogWarning("SECURITY WARNING: Server certificate validation disabled for LmxProxy gRPC connection.");
return true;
}
X509Certificate2 certificate2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate);
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
string dnsName = certificate2.GetNameInfo(X509NameType.DnsName, forIssuer: false);
if (!string.Equals(dnsName, tlsConfiguration.ServerNameOverride, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("Server certificate subject '{Subject}' does not match expected host '{ExpectedHost}'",
dnsName, tlsConfiguration.ServerNameOverride);
return false;
}
}
using X509Chain validationChain = chain ?? new X509Chain();
validationChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
validationChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerCaCertificatePath) &&
File.Exists(tlsConfiguration.ServerCaCertificatePath))
{
try
{
X509Certificate2 ca = LoadCertificate(tlsConfiguration.ServerCaCertificatePath);
validationChain.ChainPolicy.CustomTrustStore.Add(ca);
validationChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load CA certificate from {Path}", tlsConfiguration.ServerCaCertificatePath);
}
}
if (tlsConfiguration.AllowSelfSignedCertificates)
{
validationChain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority;
}
bool isValid = validationChain.Build(certificate2);
if (isValid)
{
return true;
}
if (tlsConfiguration.AllowSelfSignedCertificates &&
validationChain.ChainStatus.All(status =>
status.Status == X509ChainStatusFlags.UntrustedRoot ||
status.Status == X509ChainStatusFlags.PartialChain))
{
logger.LogWarning("Accepting self-signed certificate for {Subject}", certificate2.Subject);
return true;
}
string statusMessage = string.Join(", ", validationChain.ChainStatus.Select(s => s.Status));
logger.LogWarning("Server certificate validation failed: {Status}", statusMessage);
return false;
}
private static X509Certificate2 LoadCertificate(string path)
{
try
{
return X509Certificate2.CreateFromPemFile(path);
}
catch
{
return new X509Certificate2(File.ReadAllBytes(path));
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for registering LmxProxyClient with dependency injection
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(this IServiceCollection services, IConfiguration configuration)
{
return services.AddLmxProxyClient(configuration, "LmxProxy");
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with a specific configuration section
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <param name="configurationSection">Name of the configuration section</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration,
string configurationSection)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with default configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configurationSection);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with custom configuration
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with custom configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with scoped lifetime
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddScopedLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a scoped client
services.AddScoped<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient();
});
return services;
}
/// <summary>
/// Adds named LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="name">Name for the client</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddNamedLmxProxyClient(
this IServiceCollection services,
string name,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a keyed singleton
services.AddKeyedSingleton<LmxProxyClient>(name, (provider, _) =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
}
/// <summary>
/// Configuration options for LmxProxyClient
/// </summary>
public class LmxProxyClientOptions
{
/// <summary>
/// Gets or sets the host address
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the port number
/// </summary>
public int Port { get; set; } = 5050;
/// <summary>
/// Gets or sets the API key
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Gets or sets the timeout duration
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets whether to use SSL
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// Gets or sets the certificate path for SSL
/// </summary>
public string? CertificatePath { get; set; }
/// <summary>
/// Gets or sets whether to enable metrics
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name
/// </summary>
public string? CorrelationIdHeader { get; set; }
/// <summary>
/// Gets or sets the retry configuration
/// </summary>
public RetryOptions? Retry { get; set; }
}
/// <summary>
/// Retry configuration options
/// </summary>
public class RetryOptions
{
/// <summary>
/// Gets or sets the maximum number of retry attempts
/// </summary>
public int MaxAttempts { get; set; } = 3;
/// <summary>
/// Gets or sets the delay between retries
/// </summary>
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}
}

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for streaming operations with the LmxProxy client
/// </summary>
public static class StreamingExtensions
{
/// <summary>
/// Reads multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag values</returns>
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int batchSize = 100,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new List<string>(batchSize);
int errorCount = 0;
const int maxConsecutiveErrors = 3;
foreach (string address in addresses)
{
batch.Add(address);
if (batch.Count >= batchSize)
{
bool success = false;
int retries = 0;
const int maxRetries = 2;
while (!success && retries < maxRetries)
{
IDictionary<string, Vtq>? results = null;
Exception? lastException = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
errorCount = 0; // Reset error count on success
success = true;
}
catch (OperationCanceledException)
{
throw; // Don't retry on cancellation
}
catch (Exception ex)
{
lastException = ex;
retries++;
errorCount++;
if (errorCount >= maxConsecutiveErrors)
{
throw new InvalidOperationException(
$"Stream reading failed after {maxConsecutiveErrors} consecutive errors", ex);
}
if (retries >= maxRetries)
{
// Log error and continue with next batch
System.Diagnostics.Debug.WriteLine($"Failed to read batch after {maxRetries} retries: {ex.Message}");
batch.Clear();
break;
}
// Wait before retry with exponential backoff
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, retries - 1)), cancellationToken);
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
batch.Clear();
}
}
}
cancellationToken.ThrowIfCancellationRequested();
}
// Process remaining items
if (batch.Count > 0)
{
IDictionary<string, Vtq>? results = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Log error for final batch but don't throw to allow partial results
System.Diagnostics.Debug.WriteLine($"Failed to read final batch: {ex.Message}");
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
}
}
}
/// <summary>
/// Writes multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="values">The values to write as an async enumerable</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of values written</returns>
public static async Task<int> WriteStreamAsync(
this ILmxProxyClient client,
IAsyncEnumerable<KeyValuePair<string, object>> values,
int batchSize = 100,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(values);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new Dictionary<string, object>(batchSize);
int totalWritten = 0;
await foreach (KeyValuePair<string, object> kvp in values.WithCancellation(cancellationToken))
{
batch[kvp.Key] = kvp.Value;
if (batch.Count >= batchSize)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
batch.Clear();
}
}
// Process remaining items
if (batch.Count > 0)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
}
return totalWritten;
}
/// <summary>
/// Processes tag values in parallel batches for maximum throughput
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="processor">The async function to process each value</param>
/// <param name="maxDegreeOfParallelism">Maximum number of concurrent operations</param>
/// <param name="cancellationToken">Cancellation token</param>
public static async Task ProcessInParallelAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
Func<string, Vtq, Task> processor,
int maxDegreeOfParallelism = 4,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
ArgumentNullException.ThrowIfNull(processor);
if (maxDegreeOfParallelism <= 0)
throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism));
var semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism);
var tasks = new List<Task>();
await foreach (KeyValuePair<string, Vtq> kvp in client.ReadStreamAsync(addresses, cancellationToken: cancellationToken))
{
await semaphore.WaitAsync(cancellationToken);
var task = Task.Run(async () =>
{
try
{
await processor(kvp.Key, kvp.Value);
}
finally
{
semaphore.Release();
}
}, cancellationToken);
tasks.Add(task);
}
await Task.WhenAll(tasks);
}
/// <summary>
/// Subscribes to multiple tags and returns updates as an async stream
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to subscribe to</param>
/// <param name="pollIntervalMs">Poll interval in milliseconds</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag updates</returns>
public static async IAsyncEnumerable<Vtq> SubscribeStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int pollIntervalMs = 1000,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
var updateChannel = System.Threading.Channels.Channel.CreateUnbounded<Vtq>();
// Setup update handler
void OnUpdate(string address, Vtq vtq)
{
updateChannel.Writer.TryWrite(vtq);
}
ISubscription subscription = await client.SubscribeAsync(addresses, OnUpdate, cancellationToken);
try
{
await foreach (Vtq update in updateChannel.Reader.ReadAllAsync(cancellationToken))
{
yield return update;
}
}
finally
{
await subscription.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<Description>gRPC client library for LmxProxy service</Description>
<PlatformTarget>AnyCPU</PlatformTarget>
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="protobuf-net.Grpc" Version="1.2.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Polly" Version="8.5.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.2.0.1" newVersion="4.2.0.1"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.6.0" newVersion="4.0.6.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Validates LmxProxy configuration settings on startup.
/// </summary>
public static class ConfigurationValidator
{
private static readonly ILogger Logger = Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the provided configuration and returns a list of validation errors.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <returns>A list of validation error messages. Empty if configuration is valid.</returns>
public static List<string> Validate(LmxProxyConfiguration configuration)
{
var errors = new List<string>();
if (configuration == null)
{
errors.Add("Configuration is null");
return errors;
}
// Validate gRPC port
if (configuration.GrpcPort <= 0 || configuration.GrpcPort > 65535)
{
errors.Add($"Invalid gRPC port: {configuration.GrpcPort}. Must be between 1 and 65535.");
}
// Validate API key configuration file
if (string.IsNullOrWhiteSpace(configuration.ApiKeyConfigFile))
{
errors.Add("API key configuration file path is not specified.");
}
// Validate Connection settings
if (configuration.Connection != null)
{
ValidateConnectionConfiguration(configuration.Connection, errors);
}
else
{
errors.Add("Connection configuration is missing.");
}
// Validate Subscription settings
if (configuration.Subscription != null)
{
ValidateSubscriptionConfiguration(configuration.Subscription, errors);
}
// Validate Service Recovery settings
if (configuration.ServiceRecovery != null)
{
ValidateServiceRecoveryConfiguration(configuration.ServiceRecovery, errors);
}
// Validate TLS settings
if (configuration.Tls != null)
{
if (!configuration.Tls.Validate())
{
errors.Add("TLS configuration validation failed. Check the logs for details.");
}
}
return errors;
}
private static void ValidateConnectionConfiguration(ConnectionConfiguration config, List<string> errors)
{
if (config.MonitorIntervalSeconds <= 0)
{
errors.Add(
$"Invalid monitor interval: {config.MonitorIntervalSeconds} seconds. Must be greater than 0.");
}
if (config.ConnectionTimeoutSeconds <= 0)
{
errors.Add(
$"Invalid connection timeout: {config.ConnectionTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.ReadTimeoutSeconds <= 0)
{
errors.Add($"Invalid read timeout: {config.ReadTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.WriteTimeoutSeconds <= 0)
{
errors.Add($"Invalid write timeout: {config.WriteTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.MaxConcurrentOperations.HasValue && config.MaxConcurrentOperations.Value <= 0)
{
errors.Add(
$"Invalid max concurrent operations: {config.MaxConcurrentOperations}. Must be greater than 0.");
}
// Validate node and galaxy names if provided
if (!string.IsNullOrWhiteSpace(config.NodeName) && config.NodeName?.Length > 255)
{
errors.Add($"Node name is too long: {config.NodeName.Length} characters. Maximum is 255.");
}
if (!string.IsNullOrWhiteSpace(config.GalaxyName) && config.GalaxyName?.Length > 255)
{
errors.Add($"Galaxy name is too long: {config.GalaxyName.Length} characters. Maximum is 255.");
}
}
private static void ValidateSubscriptionConfiguration(SubscriptionConfiguration config, List<string> errors)
{
if (config.ChannelCapacity <= 0)
{
errors.Add($"Invalid channel capacity: {config.ChannelCapacity}. Must be greater than 0.");
}
if (config.ChannelCapacity > 100000)
{
errors.Add($"Channel capacity too large: {config.ChannelCapacity}. Maximum recommended is 100000.");
}
string[] validChannelModes = { "DropOldest", "DropNewest", "Wait" };
if (!validChannelModes.Contains(config.ChannelFullMode))
{
errors.Add(
$"Invalid channel full mode: {config.ChannelFullMode}. Valid values are: {string.Join(", ", validChannelModes)}");
}
}
private static void ValidateServiceRecoveryConfiguration(ServiceRecoveryConfiguration config,
List<string> errors)
{
if (config.FirstFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid first failure delay: {config.FirstFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.SecondFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid second failure delay: {config.SecondFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.SubsequentFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid subsequent failure delay: {config.SubsequentFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.ResetPeriodDays <= 0)
{
errors.Add($"Invalid reset period: {config.ResetPeriodDays} days. Must be greater than 0.");
}
}
/// <summary>
/// Logs validation results and returns whether the configuration is valid.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <returns>True if configuration is valid; otherwise, false.</returns>
public static bool ValidateAndLog(LmxProxyConfiguration configuration)
{
List<string> errors = Validate(configuration);
if (errors.Any())
{
Logger.Error("Configuration validation failed with {ErrorCount} errors:", errors.Count);
foreach (string? error in errors)
{
Logger.Error(" - {ValidationError}", error);
}
return false;
}
Logger.Information("Configuration validation successful");
return true;
}
/// <summary>
/// Throws an exception if the configuration is invalid.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
public static void ValidateOrThrow(LmxProxyConfiguration configuration)
{
List<string> errors = Validate(configuration);
if (errors.Any())
{
string message = $"Configuration validation failed with {errors.Count} error(s):\n" +
string.Join("\n", errors.Select(e => $" - {e}"));
throw new InvalidOperationException(message);
}
}
}
}

View File

@@ -0,0 +1,110 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration settings for LmxProxy service
/// </summary>
public class LmxProxyConfiguration
{
/// <summary>
/// gRPC server port
/// </summary>
public int GrpcPort { get; set; } = 50051;
/// <summary>
/// Subscription management settings
/// </summary>
public SubscriptionConfiguration Subscription { get; set; } = new();
/// <summary>
/// Windows service recovery settings
/// </summary>
public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new();
/// <summary>
/// API key configuration file path
/// </summary>
public string ApiKeyConfigFile { get; set; } = "apikeys.json";
/// <summary>
/// MxAccess connection settings
/// </summary>
public ConnectionConfiguration Connection { get; set; } = new();
/// <summary>
/// TLS/SSL configuration for secure gRPC communication
/// </summary>
public TlsConfiguration Tls { get; set; } = new();
/// <summary>
/// Web server configuration for status display
/// </summary>
public WebServerConfiguration WebServer { get; set; } = new();
}
/// <summary>
/// Configuration for MxAccess connection monitoring and reconnection
/// </summary>
public class ConnectionConfiguration
{
/// <summary>
/// Interval in seconds between connection health checks
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Timeout in seconds for initial connection attempts
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Whether to automatically reconnect when connection is lost
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Timeout in seconds for read operations
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Timeout in seconds for write operations
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Maximum number of concurrent read/write operations allowed
/// </summary>
public int? MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Name of the node to connect to (optional)
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Name of the galaxy to connect to (optional)
/// </summary>
public string? GalaxyName { get; set; }
}
/// <summary>
/// Configuration for web server that displays status information
/// </summary>
public class WebServerConfiguration
{
/// <summary>
/// Whether the web server is enabled
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Port number for the web server
/// </summary>
public int Port { get; set; } = 8080;
/// <summary>
/// Prefix URL for the web server (default: http://+:{Port}/)
/// </summary>
public string? Prefix { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for Windows service recovery
/// </summary>
public class ServiceRecoveryConfiguration
{
/// <summary>
/// Minutes to wait before restart on first failure
/// </summary>
public int FirstFailureDelayMinutes { get; set; } = 1;
/// <summary>
/// Minutes to wait before restart on second failure
/// </summary>
public int SecondFailureDelayMinutes { get; set; } = 5;
/// <summary>
/// Minutes to wait before restart on subsequent failures
/// </summary>
public int SubsequentFailureDelayMinutes { get; set; } = 10;
/// <summary>
/// Days before resetting the failure count
/// </summary>
public int ResetPeriodDays { get; set; } = 1;
}
}

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for subscription management
/// </summary>
public class SubscriptionConfiguration
{
/// <summary>
/// Buffer size for each client's channel (number of messages)
/// </summary>
public int ChannelCapacity { get; set; } = 1000;
/// <summary>
/// Strategy when channel buffer is full: "DropOldest", "DropNewest", or "Wait"
/// </summary>
public string ChannelFullMode { get; set; } = "DropOldest";
}
}

View File

@@ -0,0 +1,90 @@
using System.IO;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for TLS/SSL settings for secure gRPC communication
/// </summary>
public class TlsConfiguration
{
/// <summary>
/// Gets or sets whether TLS is enabled for gRPC communication
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the path to the server certificate file (.pem or .crt)
/// </summary>
public string ServerCertificatePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to the server private key file (.key)
/// </summary>
public string ServerKeyPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to the certificate authority file for client certificate validation (optional)
/// </summary>
public string? ClientCaCertificatePath { get; set; }
/// <summary>
/// Gets or sets whether to require client certificates for mutual TLS
/// </summary>
public bool RequireClientCertificate { get; set; } = false;
/// <summary>
/// Gets or sets whether to check certificate revocation
/// </summary>
public bool CheckCertificateRevocation { get; set; } = true;
/// <summary>
/// Validates the TLS configuration
/// </summary>
/// <returns>True if configuration is valid, false otherwise</returns>
public bool Validate()
{
if (!Enabled)
{
return true; // No validation needed if TLS is disabled
}
if (string.IsNullOrWhiteSpace(ServerCertificatePath))
{
Log.Error("TLS is enabled but ServerCertificatePath is not configured");
return false;
}
if (string.IsNullOrWhiteSpace(ServerKeyPath))
{
Log.Error("TLS is enabled but ServerKeyPath is not configured");
return false;
}
if (!File.Exists(ServerCertificatePath))
{
Log.Warning("Server certificate file not found: {Path} - will be auto-generated on startup",
ServerCertificatePath);
}
if (!File.Exists(ServerKeyPath))
{
Log.Warning("Server key file not found: {Path} - will be auto-generated on startup", ServerKeyPath);
}
if (RequireClientCertificate && string.IsNullOrWhiteSpace(ClientCaCertificatePath))
{
Log.Error("Client certificate is required but ClientCaCertificatePath is not configured");
return false;
}
if (!string.IsNullOrWhiteSpace(ClientCaCertificatePath) && !File.Exists(ClientCaCertificatePath))
{
Log.Warning("Client CA certificate file not found: {Path} - will be auto-generated on startup",
ClientCaCertificatePath);
}
return true;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Per-client subscription statistics.
/// </summary>
public class ClientStats
{
/// <summary>
/// Gets or sets the number of tags the client is subscribed to.
/// </summary>
public int SubscribedTags { get; set; }
/// <summary>
/// Gets or sets the number of delivered messages.
/// </summary>
public long DeliveredMessages { get; set; }
/// <summary>
/// Gets or sets the number of dropped messages.
/// </summary>
public long DroppedMessages { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Represents the state of a SCADA client connection.
/// </summary>
public enum ConnectionState
{
/// <summary>
/// The client is disconnected.
/// </summary>
Disconnected,
/// <summary>
/// The client is in the process of connecting.
/// </summary>
Connecting,
/// <summary>
/// The client is connected.
/// </summary>
Connected,
/// <summary>
/// The client is in the process of disconnecting.
/// </summary>
Disconnecting,
/// <summary>
/// The client encountered an error.
/// </summary>
Error,
/// <summary>
/// The client is reconnecting after a connection loss.
/// </summary>
Reconnecting
}
}

View File

@@ -0,0 +1,45 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Event arguments for SCADA client connection state changes.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previousState">The previous connection state.</param>
/// <param name="currentState">The current connection state.</param>
/// <param name="message">Optional message providing additional information about the state change.</param>
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
string? message = null)
{
PreviousState = previousState;
CurrentState = currentState;
Timestamp = DateTime.UtcNow;
Message = message;
}
/// <summary>
/// Gets the previous connection state.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets the timestamp when the state change occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets additional information about the state change, such as error messages.
/// </summary>
public string? Message { get; }
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Interface for SCADA system clients.
/// </summary>
public interface IScadaClient : IAsyncDisposable
{
/// <summary>
/// Gets the connection status.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
ConnectionState ConnectionState { get; }
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
/// <summary>
/// Connects to the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Disconnects from the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The value, timestamp, and quality.</returns>
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="addresses">The tag addresses.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary of address to VTQ values.</returns>
Task<IReadOnlyDictionary<string, Vtq>>
ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken ct = default);
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="values">Dictionary of address to value.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
/// <summary>
/// Writes a batch of tag values and a flag tag, then waits for a response tag to
/// equal the expected value.
/// </summary>
/// <param name="values">The regular tag values to write.</param>
/// <param name="flagAddress">The address of the flag tag to write.</param>
/// <param name="flagValue">The value to write to the flag tag.</param>
/// <param name="responseAddress">The address of the response tag to monitor.</param>
/// <param name="responseValue">The expected value of the response tag.</param>
/// <param name="ct">Cancellation token controlling the wait.</param>
/// <returns>
/// <c>true</c> if the response value was observed before cancellation;
/// otherwise <c>false</c>.
/// </returns>
Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
CancellationToken ct = default);
/// <summary>
/// Subscribes to value changes for specified addresses.
/// </summary>
/// <param name="addresses">The tag addresses to monitor.</param>
/// <param name="callback">Callback for value changes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Subscription handle for unsubscribing.</returns>
Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,124 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// OPC quality codes mapped to domain-level values.
/// The byte value matches the low-order byte of the OPC UA StatusCode,
/// so it can be persisted or round-tripped without translation.
/// </summary>
public enum Quality : byte
{
// ─────────────── Bad family (0-31) ───────────────
/// <summary>0x00 Bad [Non-Specific]</summary>
Bad = 0,
/// <summary>0x01 Unknown quality value</summary>
Unknown = 1,
/// <summary>0x04 Bad [Configuration Error]</summary>
Bad_ConfigError = 4,
/// <summary>0x08 Bad [Not Connected]</summary>
Bad_NotConnected = 8,
/// <summary>0x0C Bad [Device Failure]</summary>
Bad_DeviceFailure = 12,
/// <summary>0x10 Bad [Sensor Failure]</summary>
Bad_SensorFailure = 16,
/// <summary>0x14 Bad [Last Known Value]</summary>
Bad_LastKnownValue = 20,
/// <summary>0x18 Bad [Communication Failure]</summary>
Bad_CommFailure = 24,
/// <summary>0x1C Bad [Out of Service]</summary>
Bad_OutOfService = 28,
// ──────────── Uncertain family (64-95) ───────────
/// <summary>0x40 Uncertain [Non-Specific]</summary>
Uncertain = 64,
/// <summary>0x41 Uncertain [Non-Specific] (Low Limited)</summary>
Uncertain_LowLimited = 65,
/// <summary>0x42 Uncertain [Non-Specific] (High Limited)</summary>
Uncertain_HighLimited = 66,
/// <summary>0x43 Uncertain [Non-Specific] (Constant)</summary>
Uncertain_Constant = 67,
/// <summary>0x44 Uncertain [Last Usable]</summary>
Uncertain_LastUsable = 68,
/// <summary>0x45 Uncertain [Last Usable] (Low Limited)</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>0x46 Uncertain [Last Usable] (High Limited)</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>0x47 Uncertain [Last Usable] (Constant)</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>0x50 Uncertain [Sensor Not Accurate]</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>0x51 Uncertain [Sensor Not Accurate] (Low Limited)</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>0x52 Uncertain [Sensor Not Accurate] (High Limited)</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>0x53 Uncertain [Sensor Not Accurate] (Constant)</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>0x54 Uncertain [EU Exceeded]</summary>
Uncertain_EuExceeded = 84,
/// <summary>0x55 Uncertain [EU Exceeded] (Low Limited)</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>0x56 Uncertain [EU Exceeded] (High Limited)</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>0x57 Uncertain [EU Exceeded] (Constant)</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>0x58 Uncertain [Sub-Normal]</summary>
Uncertain_SubNormal = 88,
/// <summary>0x59 Uncertain [Sub-Normal] (Low Limited)</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>0x5A Uncertain [Sub-Normal] (High Limited)</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>0x5B Uncertain [Sub-Normal] (Constant)</summary>
Uncertain_SubNormal_C = 91,
// ─────────────── Good family (192-219) ────────────
/// <summary>0xC0 Good [Non-Specific]</summary>
Good = 192,
/// <summary>0xC1 Good [Non-Specific] (Low Limited)</summary>
Good_LowLimited = 193,
/// <summary>0xC2 Good [Non-Specific] (High Limited)</summary>
Good_HighLimited = 194,
/// <summary>0xC3 Good [Non-Specific] (Constant)</summary>
Good_Constant = 195,
/// <summary>0xD8 Good [Local Override]</summary>
Good_LocalOverride = 216,
/// <summary>0xD9 Good [Local Override] (Low Limited)</summary>
Good_LocalOverride_LL = 217,
/// <summary>0xDA Good [Local Override] (High Limited)</summary>
Good_LocalOverride_HL = 218,
/// <summary>0xDB Good [Local Override] (Constant)</summary>
Good_LocalOverride_C = 219
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Subscription statistics for all clients and tags.
/// </summary>
public class SubscriptionStats
{
/// <summary>
/// Gets or sets the total number of clients.
/// </summary>
public int TotalClients { get; set; }
/// <summary>
/// Gets or sets the total number of tags.
/// </summary>
public int TotalTags { get; set; }
/// <summary>
/// Gets or sets the mapping of tag addresses to client counts.
/// </summary>
public Dictionary<string, int> TagClientCounts { get; set; } = new();
/// <summary>
/// Gets or sets the mapping of client IDs to their statistics.
/// </summary>
public Dictionary<string, ClientStats> ClientStats { get; set; } = new();
}
}

View File

@@ -0,0 +1,129 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the value.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp when the value was read.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality of the value.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
Timestamp = timestamp;
Quality = quality;
}
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value and quality, using the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value, timestamp, and quality.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with good quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with good quality.</returns>
public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with bad quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value. Optional.</param>
/// <returns>A new <see cref="Vtq" /> instance with bad quality.</returns>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with uncertain quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with uncertain quality.</returns>
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Determines whether the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="other">The <see cref="Vtq" /> to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
/// <summary>
/// Determines whether the specified object is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="obj">The object to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified object is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public override bool Equals(object obj) => obj is Vtq other && Equals(other);
/// <summary>
/// Returns the hash code for this instance.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode()
{
unchecked
{
int hashCode = Value != null ? Value.GetHashCode() : 0;
hashCode = (hashCode * 397) ^ Timestamp.GetHashCode();
hashCode = (hashCode * 397) ^ (int)Quality;
return hashCode;
}
}
/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString() =>
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are equal; otherwise, false.</returns>
public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are not equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are not equal; otherwise, false.</returns>
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
}
}

View File

@@ -0,0 +1,166 @@
syntax = "proto3";
option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc";
package scada;
// The SCADA service definition
service ScadaService {
// Connection management
rpc Connect(ConnectRequest) returns (ConnectResponse);
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
// Read operations
rpc Read(ReadRequest) returns (ReadResponse);
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
// Write operations
rpc Write(WriteRequest) returns (WriteResponse);
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
// Subscription operations (server streaming) - now streams VtqMessage directly
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
// Authentication
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
}
// === CONNECTION MESSAGES ===
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;
}
// === VTQ MESSAGE ===
message VtqMessage {
string tag = 1;
string value = 2;
int64 timestamp_utc_ticks = 3;
string quality = 4; // "Good", "Uncertain", "Bad"
}
// === READ MESSAGES ===
message ReadRequest {
string session_id = 1;
string tag = 2;
}
message ReadResponse {
bool success = 1;
string message = 2;
VtqMessage vtq = 3;
}
message ReadBatchRequest {
string session_id = 1;
repeated string tags = 2;
}
message ReadBatchResponse {
bool success = 1;
string message = 2;
repeated VtqMessage vtqs = 3;
}
// === WRITE MESSAGES ===
message WriteRequest {
string session_id = 1;
string tag = 2;
string value = 3;
}
message WriteResponse {
bool success = 1;
string message = 2;
}
message WriteItem {
string tag = 1;
string value = 2;
}
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;
}
message WriteBatchAndWaitRequest {
string session_id = 1;
repeated WriteItem items = 2;
string flag_tag = 3;
string flag_value = 4;
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 MESSAGES ===
message SubscribeRequest {
string session_id = 1;
repeated string tags = 2;
int32 sampling_ms = 3;
}
// Note: Subscribe RPC now streams VtqMessage directly (defined above)
// === AUTHENTICATION MESSAGES ===
message CheckApiKeyRequest {
string api_key = 1;
}
message CheckApiKeyResponse {
bool is_valid = 1;
string message = 2;
}

View File

@@ -0,0 +1,804 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Grpc.Core;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Security;
using ZB.MOM.WW.LmxProxy.Host.Services;
using ZB.MOM.WW.LmxProxy.Host.Grpc;
namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
{
/// <summary>
/// gRPC service implementation for SCADA operations.
/// Provides methods for connecting, reading, writing, batch operations, and subscriptions.
/// </summary>
public class ScadaGrpcService : ScadaService.ScadaServiceBase
{
private static readonly ILogger Logger = Log.ForContext<ScadaGrpcService>();
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SessionManager _sessionManager;
private readonly SubscriptionManager _subscriptionManager;
/// <summary>
/// Initializes a new instance of the <see cref="ScadaGrpcService" /> class.
/// </summary>
/// <param name="scadaClient">The SCADA client instance.</param>
/// <param name="subscriptionManager">The subscription manager instance.</param>
/// <param name="sessionManager">The session manager instance.</param>
/// <param name="performanceMetrics">Optional performance metrics service for tracking operations.</param>
/// <exception cref="ArgumentNullException">Thrown if any required argument is null.</exception>
public ScadaGrpcService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
SessionManager sessionManager,
PerformanceMetrics performanceMetrics = null)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_performanceMetrics = performanceMetrics;
}
#region Connection Management
/// <summary>
/// Creates a new session for a client.
/// The MxAccess connection is managed separately at server startup.
/// </summary>
/// <param name="request">The connection request with client ID and API key.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ConnectResponse" /> with session ID.</returns>
public override Task<ConnectResponse> Connect(ConnectRequest request, ServerCallContext context)
{
try
{
Logger.Information("Connect request from {Peer} - ClientId: {ClientId}",
context.Peer, request.ClientId);
// Validate that MxAccess is connected
if (!_scadaClient.IsConnected)
{
return Task.FromResult(new ConnectResponse
{
Success = false,
Message = "SCADA server is not connected to MxAccess",
SessionId = string.Empty
});
}
// Create a new session
var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey);
return Task.FromResult(new ConnectResponse
{
Success = true,
Message = "Session created successfully",
SessionId = sessionId
});
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create session for client {ClientId}", request.ClientId);
return Task.FromResult(new ConnectResponse
{
Success = false,
Message = ex.Message,
SessionId = string.Empty
});
}
}
/// <summary>
/// Terminates a client session.
/// </summary>
/// <param name="request">The disconnect request with session ID.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="DisconnectResponse" /> indicating success or failure.</returns>
public override Task<DisconnectResponse> Disconnect(DisconnectRequest request, ServerCallContext context)
{
try
{
Logger.Information("Disconnect request from {Peer} - SessionId: {SessionId}",
context.Peer, request.SessionId);
var terminated = _sessionManager.TerminateSession(request.SessionId);
return Task.FromResult(new DisconnectResponse
{
Success = terminated,
Message = terminated ? "Session terminated successfully" : "Session not found"
});
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to disconnect session {SessionId}", request.SessionId);
return Task.FromResult(new DisconnectResponse
{
Success = false,
Message = ex.Message
});
}
}
/// <summary>
/// Gets the connection state for a session.
/// </summary>
/// <param name="request">The connection state request with session ID.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="GetConnectionStateResponse" /> with connection details.</returns>
public override Task<GetConnectionStateResponse> GetConnectionState(GetConnectionStateRequest request,
ServerCallContext context)
{
var session = _sessionManager.GetSession(request.SessionId);
if (session == null)
{
return Task.FromResult(new GetConnectionStateResponse
{
IsConnected = false,
ClientId = string.Empty,
ConnectedSinceUtcTicks = 0
});
}
return Task.FromResult(new GetConnectionStateResponse
{
IsConnected = _scadaClient.IsConnected,
ClientId = session.ClientId,
ConnectedSinceUtcTicks = session.ConnectedSinceUtcTicks
});
}
#endregion
#region Read Operations
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="request">The read request with session ID and tag.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ReadResponse" /> with the VTQ data.</returns>
public override async Task<ReadResponse> Read(ReadRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Read"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
return new ReadResponse
{
Success = false,
Message = "Invalid session ID",
Vtq = CreateBadVtqMessage(request.Tag)
};
}
Logger.Debug("Read request from {Peer} for {Tag}", context.Peer, request.Tag);
Vtq vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken);
scope?.SetSuccess(true);
return new ReadResponse
{
Success = true,
Message = string.Empty,
Vtq = ConvertToVtqMessage(request.Tag, vtq)
};
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to read {Tag}", request.Tag);
scope?.SetSuccess(false);
return new ReadResponse
{
Success = false,
Message = ex.Message,
Vtq = CreateBadVtqMessage(request.Tag)
};
}
}
}
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="request">The batch read request with session ID and tags.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ReadBatchResponse" /> with VTQ data for each tag.</returns>
public override async Task<ReadBatchResponse> ReadBatch(ReadBatchRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("ReadBatch"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new ReadBatchResponse
{
Success = false,
Message = "Invalid session ID"
};
foreach (var tag in request.Tags)
{
badResponse.Vtqs.Add(CreateBadVtqMessage(tag));
}
return badResponse;
}
Logger.Debug("ReadBatch request from {Peer} for {Count} tags", context.Peer, request.Tags.Count);
IReadOnlyDictionary<string, Vtq> results =
await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken);
var response = new ReadBatchResponse
{
Success = true,
Message = string.Empty
};
// Return results in the same order as the request tags
foreach (var tag in request.Tags)
{
if (results.TryGetValue(tag, out Vtq vtq))
{
response.Vtqs.Add(ConvertToVtqMessage(tag, vtq));
}
else
{
response.Vtqs.Add(CreateBadVtqMessage(tag));
}
}
scope?.SetSuccess(true);
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to read batch");
scope?.SetSuccess(false);
var response = new ReadBatchResponse
{
Success = false,
Message = ex.Message
};
foreach (var tag in request.Tags)
{
response.Vtqs.Add(CreateBadVtqMessage(tag));
}
return response;
}
}
}
#endregion
#region Write Operations
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="request">The write request with session ID, tag, and value.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteResponse" /> indicating success or failure.</returns>
public override async Task<WriteResponse> Write(WriteRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Write"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
return new WriteResponse
{
Success = false,
Message = "Invalid session ID"
};
}
Logger.Debug("Write request from {Peer} for {Tag}", context.Peer, request.Tag);
// Parse the string value to an appropriate type
var value = ParseValue(request.Value);
await _scadaClient.WriteAsync(request.Tag, value, context.CancellationToken);
scope?.SetSuccess(true);
return new WriteResponse
{
Success = true,
Message = string.Empty
};
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write to {Tag}", request.Tag);
scope?.SetSuccess(false);
return new WriteResponse
{
Success = false,
Message = ex.Message
};
}
}
}
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="request">The batch write request with session ID and items.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteBatchResponse" /> with results for each tag.</returns>
public override async Task<WriteBatchResponse> WriteBatch(WriteBatchRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("WriteBatch"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new WriteBatchResponse
{
Success = false,
Message = "Invalid session ID"
};
foreach (var item in request.Items)
{
badResponse.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = "Invalid session ID"
});
}
return badResponse;
}
Logger.Debug("WriteBatch request from {Peer} for {Count} items", context.Peer, request.Items.Count);
var values = new Dictionary<string, object>();
foreach (var item in request.Items)
{
values[item.Tag] = ParseValue(item.Value);
}
await _scadaClient.WriteBatchAsync(values, context.CancellationToken);
scope?.SetSuccess(true);
var response = new WriteBatchResponse
{
Success = true,
Message = string.Empty
};
foreach (var item in request.Items)
{
response.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = true,
Message = string.Empty
});
}
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write batch");
scope?.SetSuccess(false);
var response = new WriteBatchResponse
{
Success = false,
Message = ex.Message
};
foreach (var item in request.Items)
{
response.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = ex.Message
});
}
return response;
}
}
}
/// <summary>
/// Writes a batch of tag values and waits for a flag tag to reach a specific value.
/// </summary>
/// <param name="request">The batch write and wait request.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteBatchAndWaitResponse" /> with results and flag status.</returns>
public override async Task<WriteBatchAndWaitResponse> WriteBatchAndWait(WriteBatchAndWaitRequest request,
ServerCallContext context)
{
var startTime = DateTime.UtcNow;
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new WriteBatchAndWaitResponse
{
Success = false,
Message = "Invalid session ID",
FlagReached = false,
ElapsedMs = 0
};
foreach (var item in request.Items)
{
badResponse.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = "Invalid session ID"
});
}
return badResponse;
}
Logger.Debug("WriteBatchAndWait request from {Peer}", context.Peer);
var values = new Dictionary<string, object>();
foreach (var item in request.Items)
{
values[item.Tag] = ParseValue(item.Value);
}
var flagValue = ParseValue(request.FlagValue);
var pollInterval = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
cts.CancelAfter(TimeSpan.FromMilliseconds(request.TimeoutMs));
// Write the batch first
await _scadaClient.WriteBatchAsync(values, cts.Token);
// Poll for the flag value
var flagReached = false;
while (!cts.Token.IsCancellationRequested)
{
try
{
var flagVtq = await _scadaClient.ReadAsync(request.FlagTag, cts.Token);
if (flagVtq.Value != null && AreValuesEqual(flagVtq.Value, flagValue))
{
flagReached = true;
break;
}
await Task.Delay(pollInterval, cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var response = new WriteBatchAndWaitResponse
{
Success = true,
Message = string.Empty,
FlagReached = flagReached,
ElapsedMs = elapsedMs
};
foreach (var item in request.Items)
{
response.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = true,
Message = string.Empty
});
}
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write batch and wait");
var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var response = new WriteBatchAndWaitResponse
{
Success = false,
Message = ex.Message,
FlagReached = false,
ElapsedMs = elapsedMs
};
foreach (var item in request.Items)
{
response.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = ex.Message
});
}
return response;
}
}
#endregion
#region Subscription Operations
/// <summary>
/// Subscribes to value changes for specified tags and streams updates to the client.
/// </summary>
/// <param name="request">The subscribe request with session ID and tags.</param>
/// <param name="responseStream">The server stream writer for VTQ updates.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public override async Task Subscribe(SubscribeRequest request,
IServerStreamWriter<VtqMessage> responseStream, ServerCallContext context)
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
Logger.Warning("Subscribe failed: Invalid session ID {SessionId}", request.SessionId);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session ID"));
}
var clientId = Guid.NewGuid().ToString();
try
{
Logger.Information("Subscribe request from {Peer} with client ID {ClientId} for {Count} tags",
context.Peer, clientId, request.Tags.Count);
Channel<(string address, Vtq vtq)> channel = await _subscriptionManager.SubscribeAsync(
clientId,
request.Tags,
context.CancellationToken);
// Stream updates to the client until cancelled
while (!context.CancellationToken.IsCancellationRequested)
{
try
{
while (await channel.Reader.WaitToReadAsync(context.CancellationToken))
{
if (channel.Reader.TryRead(out (string address, Vtq vtq) item))
{
var vtqMessage = ConvertToVtqMessage(item.address, item.vtq);
await responseStream.WriteAsync(vtqMessage);
}
}
}
catch (OperationCanceledException)
{
break;
}
}
}
catch (OperationCanceledException)
{
Logger.Information("Subscription cancelled for client {ClientId}", clientId);
}
catch (Exception ex)
{
Logger.Error(ex, "Error in subscription for client {ClientId}", clientId);
throw;
}
finally
{
_subscriptionManager.UnsubscribeClient(clientId);
}
}
#endregion
#region Authentication
/// <summary>
/// Checks the validity of an API key.
/// </summary>
/// <param name="request">The API key check request.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="CheckApiKeyResponse" /> with validity and details.</returns>
public override Task<CheckApiKeyResponse> CheckApiKey(CheckApiKeyRequest request, ServerCallContext context)
{
var response = new CheckApiKeyResponse
{
IsValid = false,
Message = "API key validation failed"
};
// Check if API key was validated by interceptor
if (context.UserState.TryGetValue("ApiKey", out object apiKeyObj) && apiKeyObj is ApiKey apiKey)
{
response.IsValid = apiKey.IsValid();
response.Message = apiKey.IsValid()
? $"API key is valid (Role: {apiKey.Role})"
: "API key is disabled";
Logger.Information("API key check - Valid: {IsValid}, Role: {Role}",
response.IsValid, apiKey.Role);
}
else
{
Logger.Warning("API key check failed - no API key in context");
}
return Task.FromResult(response);
}
#endregion
#region Value Conversion Helpers
/// <summary>
/// Converts a domain <see cref="Vtq" /> to a gRPC <see cref="VtqMessage" />.
/// </summary>
private static VtqMessage ConvertToVtqMessage(string tag, Vtq vtq)
{
return new VtqMessage
{
Tag = tag,
Value = ConvertValueToString(vtq.Value),
TimestampUtcTicks = vtq.Timestamp.Ticks,
Quality = ConvertQualityToString(vtq.Quality)
};
}
/// <summary>
/// Creates a bad quality VTQ message for error cases.
/// </summary>
private static VtqMessage CreateBadVtqMessage(string tag)
{
return new VtqMessage
{
Tag = tag,
Value = string.Empty,
TimestampUtcTicks = DateTime.UtcNow.Ticks,
Quality = "Bad"
};
}
/// <summary>
/// Converts a value to its string representation.
/// </summary>
private static string ConvertValueToString(object value)
{
if (value == null)
{
return string.Empty;
}
return value switch
{
bool b => b.ToString().ToLowerInvariant(),
DateTime dt => dt.ToUniversalTime().ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
float f => f.ToString(CultureInfo.InvariantCulture),
double d => d.ToString(CultureInfo.InvariantCulture),
decimal dec => dec.ToString(CultureInfo.InvariantCulture),
Array => JsonSerializer.Serialize(value, value.GetType()),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Converts a domain quality value to a string.
/// </summary>
private static string ConvertQualityToString(Domain.Quality quality)
{
// Simplified quality mapping for the new API
var qualityValue = (int)quality;
if (qualityValue >= 192) // Good family
{
return "Good";
}
if (qualityValue >= 64) // Uncertain family
{
return "Uncertain";
}
return "Bad"; // Bad family
}
/// <summary>
/// Parses a string value to an appropriate .NET type.
/// </summary>
private static object ParseValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
// Try to parse as boolean
if (bool.TryParse(value, out bool boolResult))
{
return boolResult;
}
// Try to parse as integer
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intResult))
{
return intResult;
}
// Try to parse as long
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long longResult))
{
return longResult;
}
// Try to parse as double
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture,
out double doubleResult))
{
return doubleResult;
}
// Try to parse as DateTime
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind,
out DateTime dateResult))
{
return dateResult;
}
// Return as string
return value;
}
/// <summary>
/// Compares two values for equality.
/// </summary>
private static bool AreValuesEqual(object value1, object value2)
{
if (value1 == null && value2 == null)
{
return true;
}
if (value1 == null || value2 == null)
{
return false;
}
// Convert both to strings for comparison
var str1 = ConvertValueToString(value1);
var str2 = ConvertValueToString(value2);
return string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase);
}
#endregion
}
}

View File

@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Connection management for MxAccessClient.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Asynchronously connects to the MxAccess server.
/// </summary>
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous connect operation.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the client has been disposed.</exception>
/// <exception cref="InvalidOperationException">Thrown if registration with MxAccess fails.</exception>
/// <exception cref="Exception">Thrown if any other error occurs during connection.</exception>
public async Task ConnectAsync(CancellationToken ct = default)
{
// COM operations must run on STA thread, so we use Task.Run here
await Task.Run(ConnectInternal, ct);
// Recreate stored subscriptions after successful connection
await RecreateStoredSubscriptionsAsync();
}
/// <summary>
/// Asynchronously disconnects from the MxAccess server and cleans up resources.
/// </summary>
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous disconnect operation.</returns>
public async Task DisconnectAsync(CancellationToken ct = default)
{
// COM operations must run on STA thread, so we use Task.Run here
await Task.Run(() => DisconnectInternal(), ct);
}
/// <summary>
/// Internal synchronous connection logic.
/// </summary>
private void ConnectInternal()
{
lock (_lock)
{
ValidateNotDisposed();
if (IsConnected)
{
return;
}
try
{
Logger.Information("Attempting to connect to MxAccess");
SetConnectionState(ConnectionState.Connecting);
InitializeMxAccessConnection();
RegisterWithMxAccess();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to connect to MxAccess");
Cleanup();
SetConnectionState(ConnectionState.Disconnected, ex.Message);
throw;
}
}
}
/// <summary>
/// Validates that the client has not been disposed.
/// </summary>
private void ValidateNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(MxAccessClient));
}
}
/// <summary>
/// Initializes the MxAccess COM connection and event handlers.
/// </summary>
private void InitializeMxAccessConnection()
{
// Create the COM object
_lmxProxy = new LMXProxyServer();
// Wire up event handlers
_lmxProxy.OnDataChange += OnDataChange;
_lmxProxy.OnWriteComplete += OnWriteComplete;
_lmxProxy.OperationComplete += OnOperationComplete;
}
/// <summary>
/// Registers with the MxAccess server.
/// </summary>
private void RegisterWithMxAccess()
{
// Register with the server
if (_lmxProxy == null)
{
throw new InvalidOperationException("MxAccess proxy is not initialized");
}
_connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host");
if (_connectionHandle > 0)
{
SetConnectionState(ConnectionState.Connected);
Logger.Information("Successfully connected to MxAccess with handle {Handle}", _connectionHandle);
}
else
{
throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned");
}
}
/// <summary>
/// Internal synchronous disconnection logic.
/// </summary>
private void DisconnectInternal()
{
lock (_lock)
{
if (!IsConnected || _lmxProxy == null)
{
return;
}
try
{
Logger.Information("Disconnecting from MxAccess");
SetConnectionState(ConnectionState.Disconnecting);
RemoveAllSubscriptions();
UnregisterFromMxAccess();
Cleanup();
SetConnectionState(ConnectionState.Disconnected);
Logger.Information("Successfully disconnected from MxAccess");
}
catch (Exception ex)
{
Logger.Error(ex, "Error during disconnect");
Cleanup();
SetConnectionState(ConnectionState.Disconnected, ex.Message);
}
}
}
/// <summary>
/// Removes all active subscriptions.
/// </summary>
private void RemoveAllSubscriptions()
{
var subscriptionsToRemove = _subscriptions.Values.ToList();
var failedRemovals = new List<string>();
foreach (SubscriptionInfo? sub in subscriptionsToRemove)
{
if (!TryRemoveSubscription(sub))
{
failedRemovals.Add(sub.Address);
}
}
if (failedRemovals.Any())
{
Logger.Warning("Failed to cleanly remove {Count} subscriptions: {Addresses}",
failedRemovals.Count, string.Join(", ", failedRemovals));
}
_subscriptions.Clear();
_subscriptionsByHandle.Clear();
// Note: We intentionally keep _storedSubscriptions to recreate them on reconnect
}
/// <summary>
/// Attempts to remove a single subscription.
/// </summary>
private bool TryRemoveSubscription(SubscriptionInfo subscription)
{
try
{
if (_lmxProxy == null)
{
return false;
}
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
return true;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error removing subscription for {Address}", subscription.Address);
return false;
}
}
/// <summary>
/// Unregisters from the MxAccess server.
/// </summary>
private void UnregisterFromMxAccess()
{
if (_connectionHandle > 0 && _lmxProxy != null)
{
_lmxProxy.Unregister(_connectionHandle);
_connectionHandle = 0;
}
}
/// <summary>
/// Cleans up resources and releases the COM object.
/// Removes event handlers and releases the proxy COM object if present.
/// </summary>
private void Cleanup()
{
try
{
if (_lmxProxy != null)
{
// Remove event handlers
_lmxProxy.OnDataChange -= OnDataChange;
_lmxProxy.OnWriteComplete -= OnWriteComplete;
_lmxProxy.OperationComplete -= OnOperationComplete;
// Release COM object
int refCount = Marshal.ReleaseComObject(_lmxProxy);
if (refCount > 0)
{
Logger.Warning("COM object reference count after release: {RefCount}", refCount);
// Force final release
while (refCount > 0)
{
refCount = Marshal.ReleaseComObject(_lmxProxy);
}
}
_lmxProxy = null;
}
_connectionHandle = 0;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error during cleanup");
}
}
/// <summary>
/// Recreates all stored subscriptions after reconnection.
/// </summary>
private async Task RecreateStoredSubscriptionsAsync()
{
List<StoredSubscription> subscriptionsToRecreate;
lock (_lock)
{
// Create a copy to avoid holding the lock during async operations
subscriptionsToRecreate = new List<StoredSubscription>(_storedSubscriptions);
}
if (subscriptionsToRecreate.Count == 0)
{
Logger.Debug("No stored subscriptions to recreate");
return;
}
Logger.Information("Recreating {Count} stored subscription groups after reconnection",
subscriptionsToRecreate.Count);
foreach (StoredSubscription? storedSub in subscriptionsToRecreate)
{
try
{
// Recreate the subscription without storing it again
await SubscribeInternalAsync(storedSub.Addresses, storedSub.Callback, false);
Logger.Information("Successfully recreated subscription group {GroupId} with {Count} addresses",
storedSub.GroupId, storedSub.Addresses.Count);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to recreate subscription group {GroupId}", storedSub.GroupId);
}
}
}
}
}

View File

@@ -0,0 +1,166 @@
using System;
using ArchestrA.MxAccess;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Event handlers for MxAccessClient to process data changes, write completions, and operation completions.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Handles data change events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="pvItemValue">Item value.</param>
/// <param name="pwItemQuality">Item quality code.</param>
/// <param name="pftItemTimeStamp">Item timestamp.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
if (!_subscriptionsByHandle.TryGetValue(phItemHandle, out SubscriptionInfo? subscription))
{
return;
}
// Convert quality from integer
Quality quality = ConvertQuality(pwItemQuality);
DateTime timestamp = ConvertTimestamp(pftItemTimeStamp);
var vtq = new Vtq(pvItemValue, timestamp, quality);
// Invoke callback
subscription.Callback?.Invoke(subscription.Address, vtq);
}
catch (Exception ex)
{
Logger.Error(ex, "Error processing data change for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// Handles write completion events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
WriteOperation? writeOp;
lock (_lock)
{
if (_pendingWrites.TryGetValue(phItemHandle, out writeOp))
{
_pendingWrites.Remove(phItemHandle);
}
}
if (writeOp != null)
{
try
{
if (ItemStatus is { Length: > 0 })
{
var status = ItemStatus[0];
if (status.success == 0)
{
string errorMsg = GetWriteErrorMessage(status.detail);
Logger.Warning(
"Write failed for {Address} (handle {Handle}): {Error} (Category={Category}, Detail={Detail})",
writeOp.Address, phItemHandle, errorMsg, status.category, status.detail);
writeOp.CompletionSource.TrySetException(new InvalidOperationException(
$"Write failed: {errorMsg}"));
}
else
{
Logger.Debug("Write completed successfully for {Address} (handle {Handle})",
writeOp.Address, phItemHandle);
writeOp.CompletionSource.TrySetResult(true);
}
}
else
{
Logger.Debug("Write completed for {Address} (handle {Handle}) with no status",
writeOp.Address, phItemHandle);
writeOp.CompletionSource.TrySetResult(true);
}
}
finally
{
// Clean up the item after write completes
lock (_lock)
{
if (_lmxProxy != null)
{
try
{
_lmxProxy.UnAdvise(_connectionHandle, phItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, phItemHandle);
}
catch (Exception ex)
{
Logger.Debug(ex, "Error cleaning up after write for handle {Handle}", phItemHandle);
}
}
}
}
}
else if (ItemStatus is { Length: > 0 })
{
var status = ItemStatus[0];
if (status.success == 0)
{
Logger.Warning("Write failed for unknown handle {Handle}: Category={Category}, Detail={Detail}",
phItemHandle, status.category, status.detail);
}
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error processing write complete for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// Handles operation completion events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnOperationComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
{
// Log operation completion
Logger.Debug("Operation complete for handle {Handle}", phItemHandle);
}
/// <summary>
/// Converts an integer MxAccess quality code to <see cref="Quality" />.
/// </summary>
/// <param name="mxQuality">The MxAccess quality code.</param>
/// <returns>The corresponding <see cref="Quality" /> value.</returns>
private Quality ConvertQuality(int mxQuality) => (Quality)mxQuality;
/// <summary>
/// Converts a timestamp object to <see cref="DateTime" /> in UTC.
/// </summary>
/// <param name="timestamp">The timestamp object.</param>
/// <returns>The UTC <see cref="DateTime" /> value.</returns>
private DateTime ConvertTimestamp(object timestamp)
{
if (timestamp is DateTime dt)
{
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
}
return DateTime.UtcNow;
}
}
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Private nested types for MxAccessClient to encapsulate subscription and write operation details.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Holds information about a subscription to a SCADA tag.
/// </summary>
private class SubscriptionInfo
{
/// <summary>
/// Gets or sets the address of the tag.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item handle.
/// </summary>
public int ItemHandle { get; set; }
/// <summary>
/// Gets or sets the callback for value changes.
/// </summary>
public Action<string, Vtq>? Callback { get; set; }
/// <summary>
/// Gets or sets the subscription identifier.
/// </summary>
public string SubscriptionId { get; set; } = string.Empty;
}
/// <summary>
/// Represents a handle for a subscription, allowing asynchronous disposal.
/// </summary>
private class SubscriptionHandle : IAsyncDisposable
{
private readonly MxAccessClient _client;
private readonly string _groupId;
private readonly List<string> _subscriptionIds;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionHandle" /> class.
/// </summary>
/// <param name="client">The owning <see cref="MxAccessClient" />.</param>
/// <param name="subscriptionIds">The subscription identifiers.</param>
/// <param name="groupId">The group identifier for stored subscriptions.</param>
public SubscriptionHandle(MxAccessClient client, List<string> subscriptionIds, string groupId)
{
_client = client;
_subscriptionIds = subscriptionIds;
_groupId = groupId;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
var tasks = new List<Task>();
foreach (string? id in _subscriptionIds)
{
tasks.Add(_client.UnsubscribeInternalAsync(id));
}
await Task.WhenAll(tasks);
// Remove the stored subscription group
_client.RemoveStoredSubscription(_groupId);
}
}
/// <summary>
/// Represents a pending write operation.
/// </summary>
private class WriteOperation
{
/// <summary>
/// Gets or sets the address of the tag.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item handle.
/// </summary>
public int ItemHandle { get; set; }
/// <summary>
/// Gets or sets the completion source for the write operation.
/// </summary>
public TaskCompletionSource<bool> CompletionSource { get; set; } = null!;
/// <summary>
/// Gets or sets the start time of the write operation.
/// </summary>
public DateTime StartTime { get; set; }
}
/// <summary>
/// Stores subscription information for automatic recreation after reconnection.
/// </summary>
private class StoredSubscription
{
/// <summary>
/// Gets or sets the addresses that were subscribed to.
/// </summary>
public List<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets the callback for value changes.
/// </summary>
public Action<string, Vtq> Callback { get; set; } = null!;
/// <summary>
/// Gets or sets the unique identifier for this stored subscription group.
/// </summary>
public string GroupId { get; set; } = string.Empty;
}
}
}

View File

@@ -0,0 +1,402 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Polly;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Services;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Read and write operations for MxAccessClient.
/// </summary>
public sealed partial class MxAccessClient
{
/// <inheritdoc />
public async Task<Vtq> ReadAsync(string address, CancellationToken ct = default)
{
// Apply retry policy for read operations
IAsyncPolicy<Vtq> policy = RetryPolicies.CreateReadPolicy<Vtq>();
return await policy.ExecuteWithRetryAsync(async () =>
{
ValidateConnection();
return await ReadSingleValueAsync(address, ct);
}, $"Read-{address}");
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses,
CancellationToken ct = default)
{
var addressList = addresses.ToList();
var results = new Dictionary<string, Vtq>(addressList.Count);
// Create tasks for parallel reading
IEnumerable<Task> tasks =
addressList.Select(address => ReadAddressWithSemaphoreAsync(address, results, ct));
await Task.WhenAll(tasks);
return results;
}
/// <inheritdoc />
public async Task WriteAsync(string address, object value, CancellationToken ct = default)
{
// Apply retry policy for write operations
IAsyncPolicy policy = RetryPolicies.CreateWritePolicy();
await policy.ExecuteWithRetryAsync(async () => { await WriteInternalAsync(address, value, ct); },
$"Write-{address}");
}
/// <inheritdoc />
public async Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default)
{
// Create tasks for parallel writing
IEnumerable<Task> tasks = values.Select(kvp => WriteAddressWithSemaphoreAsync(kvp.Key, kvp.Value, ct));
await Task.WhenAll(tasks);
}
/// <inheritdoc />
public async Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
CancellationToken ct = default)
{
// Write the batch values
await WriteBatchAsync(values, ct);
// Write the flag
await WriteAsync(flagAddress, flagValue, ct);
// Wait for the response
return await WaitForResponseAsync(responseAddress, responseValue, ct);
}
#region Private Helper Methods
/// <summary>
/// Validates that the client is connected.
/// </summary>
private void ValidateConnection()
{
if (!IsConnected)
{
throw new InvalidOperationException("Not connected to MxAccess");
}
}
/// <summary>
/// Reads a single value from the specified address.
/// </summary>
private async Task<Vtq> ReadSingleValueAsync(string address, CancellationToken ct)
{
// MxAccess doesn't support direct read - we need to subscribe, get the value, then unsubscribe
var tcs = new TaskCompletionSource<Vtq>();
IAsyncDisposable? subscription = null;
try
{
subscription = await SubscribeAsync(new[] { address }, (addr, vtq) => { tcs.TrySetResult(vtq); }, ct);
return await WaitForReadResultAsync(tcs, ct);
}
finally
{
if (subscription != null)
{
await subscription.DisposeAsync();
}
}
}
/// <summary>
/// Waits for a read result with timeout.
/// </summary>
private async Task<Vtq> WaitForReadResultAsync(TaskCompletionSource<Vtq> tcs, CancellationToken ct)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_configuration.ReadTimeoutSeconds)))
{
using (ct.Register(() => cts.Cancel()))
{
cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Read timeout")));
return await tcs.Task;
}
}
}
/// <summary>
/// Reads an address with semaphore protection for batch operations.
/// </summary>
private async Task ReadAddressWithSemaphoreAsync(string address, Dictionary<string, Vtq> results,
CancellationToken ct)
{
await _readSemaphore.WaitAsync(ct);
try
{
Vtq vtq = await ReadAsync(address, ct);
lock (results)
{
results[address] = vtq;
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to read {Address}", address);
lock (results)
{
results[address] = Vtq.Bad();
}
}
finally
{
_readSemaphore.Release();
}
}
/// <summary>
/// Internal write implementation.
/// </summary>
private async Task WriteInternalAsync(string address, object value, CancellationToken ct)
{
var tcs = new TaskCompletionSource<bool>();
int itemHandle = await SetupWriteOperationAsync(address, value, tcs, ct);
try
{
await WaitForWriteCompletionAsync(tcs, itemHandle, address, ct);
}
catch
{
await CleanupWriteOperationAsync(itemHandle);
throw;
}
}
/// <summary>
/// Sets up a write operation and returns the item handle.
/// </summary>
private async Task<int> SetupWriteOperationAsync(string address, object value, TaskCompletionSource<bool> tcs,
CancellationToken ct)
{
return await Task.Run(() =>
{
lock (_lock)
{
ValidateConnectionLocked();
return InitiateWriteOperation(address, value, tcs);
}
}, ct);
}
/// <summary>
/// Validates connection while holding the lock.
/// </summary>
private void ValidateConnectionLocked()
{
if (!IsConnected || _lmxProxy == null)
{
throw new InvalidOperationException("Not connected to MxAccess");
}
}
/// <summary>
/// Initiates a write operation and returns the item handle.
/// </summary>
private int InitiateWriteOperation(string address, object value, TaskCompletionSource<bool> tcs)
{
int itemHandle = 0;
try
{
if (_lmxProxy == null)
{
throw new InvalidOperationException("MxAccess proxy is not initialized");
}
// Add the item if not already added
itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// Advise the item to enable writing
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
// Track the pending write operation
TrackPendingWrite(address, itemHandle, tcs);
// Write the value
_lmxProxy.Write(_connectionHandle, itemHandle, value, -1); // -1 for no security
return itemHandle;
}
catch (Exception ex)
{
CleanupFailedWrite(itemHandle);
Logger.Error(ex, "Failed to write value to {Address}", address);
throw;
}
}
/// <summary>
/// Tracks a pending write operation.
/// </summary>
private void TrackPendingWrite(string address, int itemHandle, TaskCompletionSource<bool> tcs)
{
var writeOp = new WriteOperation
{
Address = address,
ItemHandle = itemHandle,
CompletionSource = tcs,
StartTime = DateTime.UtcNow
};
_pendingWrites[itemHandle] = writeOp;
}
/// <summary>
/// Cleans up a failed write operation.
/// </summary>
private void CleanupFailedWrite(int itemHandle)
{
if (itemHandle > 0 && _lmxProxy != null)
{
try
{
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
_pendingWrites.Remove(itemHandle);
}
catch
{
}
}
}
/// <summary>
/// Waits for write completion with timeout.
/// </summary>
private async Task WaitForWriteCompletionAsync(TaskCompletionSource<bool> tcs, int itemHandle, string address,
CancellationToken ct)
{
using (ct.Register(() => tcs.TrySetCanceled()))
{
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(_configuration.WriteTimeoutSeconds), ct);
Task? completedTask = await Task.WhenAny(tcs.Task, timeoutTask);
if (completedTask == timeoutTask)
{
await HandleWriteTimeoutAsync(itemHandle, address);
}
await tcs.Task; // This will throw if the write failed
}
}
/// <summary>
/// Handles write timeout by cleaning up resources.
/// </summary>
private async Task HandleWriteTimeoutAsync(int itemHandle, string address)
{
await CleanupWriteOperationAsync(itemHandle);
throw new TimeoutException($"Write operation to {address} timed out");
}
/// <summary>
/// Cleans up a write operation.
/// </summary>
private async Task CleanupWriteOperationAsync(int itemHandle)
{
await Task.Run(() =>
{
lock (_lock)
{
if (_pendingWrites.ContainsKey(itemHandle))
{
_pendingWrites.Remove(itemHandle);
if (_lmxProxy != null)
{
try
{
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
}
catch
{
}
}
}
}
});
}
/// <summary>
/// Writes an address with semaphore protection for batch operations.
/// </summary>
private async Task WriteAddressWithSemaphoreAsync(string address, object value, CancellationToken ct)
{
await _writeSemaphore.WaitAsync(ct);
try
{
await WriteAsync(address, value, ct);
}
finally
{
_writeSemaphore.Release();
}
}
/// <summary>
/// Waits for a specific response value.
/// </summary>
private async Task<bool> WaitForResponseAsync(string responseAddress, object responseValue,
CancellationToken ct)
{
var tcs = new TaskCompletionSource<bool>();
IAsyncDisposable? subscription = null;
try
{
subscription = await SubscribeAsync(new[] { responseAddress }, (addr, vtq) =>
{
if (Equals(vtq.Value, responseValue))
{
tcs.TrySetResult(true);
}
}, ct);
// Wait for the response value
using (ct.Register(() => tcs.TrySetResult(false)))
{
return await tcs.Task;
}
}
finally
{
if (subscription != null)
{
await subscription.DisposeAsync();
}
}
}
/// <summary>
/// Gets a human-readable error message for a write error code.
/// </summary>
/// <param name="errorCode">The error code.</param>
/// <returns>The error message.</returns>
private static string GetWriteErrorMessage(int errorCode)
{
return errorCode switch
{
1008 => "User lacks proper security for write operation",
1012 => "Secured write required",
1013 => "Verified write required",
_ => $"Unknown error code: {errorCode}"
};
}
#endregion
}
}

View File

@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Subscription management for MxAccessClient to handle SCADA tag updates.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Subscribes to a set of addresses and registers a callback for value changes.
/// </summary>
/// <param name="addresses">The collection of addresses to subscribe to.</param>
/// <param name="callback">
/// The callback to invoke when a value changes.
/// The callback receives the address and the new <see cref="Vtq" /> value.
/// </param>
/// <param name="ct">An optional <see cref="CancellationToken" /> to cancel the operation.</param>
/// <returns>
/// A <see cref="Task{IAsyncDisposable}" /> that completes with a handle to the subscription.
/// Disposing the handle will unsubscribe from all addresses.
/// </returns>
/// <exception cref="InvalidOperationException">Thrown if not connected to MxAccess.</exception>
/// <exception cref="Exception">Thrown if subscription fails for any address.</exception>
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
CancellationToken ct = default) => SubscribeInternalAsync(addresses, callback, true, ct);
/// <summary>
/// Internal subscription method that allows control over whether to store the subscription for recreation.
/// </summary>
private Task<IAsyncDisposable> SubscribeInternalAsync(IEnumerable<string> addresses,
Action<string, Vtq> callback, bool storeForRecreation, CancellationToken ct = default)
{
return Task.Run<IAsyncDisposable>(() =>
{
lock (_lock)
{
if (!IsConnected || _lmxProxy == null)
{
throw new InvalidOperationException("Not connected to MxAccess");
}
var subscriptionIds = new List<string>();
try
{
var addressList = addresses.ToList();
foreach (string? address in addressList)
{
// Add the item
var itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// Create subscription info
string subscriptionId = Guid.NewGuid().ToString();
var subscription = new SubscriptionInfo
{
Address = address,
ItemHandle = itemHandle,
Callback = callback,
SubscriptionId = subscriptionId
};
// Store subscription
_subscriptions[subscriptionId] = subscription;
_subscriptionsByHandle[itemHandle] = subscription;
subscriptionIds.Add(subscriptionId);
// Advise the item
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
Logger.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
}
// Store subscription group for automatic recreation after reconnect
string groupId = Guid.NewGuid().ToString();
if (storeForRecreation)
{
_storedSubscriptions.Add(new StoredSubscription
{
Addresses = addressList,
Callback = callback,
GroupId = groupId
});
Logger.Debug(
"Stored subscription group {GroupId} with {Count} addresses for automatic recreation",
groupId, addressList.Count);
}
return new SubscriptionHandle(this, subscriptionIds, groupId);
}
catch (Exception ex)
{
// Clean up any subscriptions that were created
foreach (string? id in subscriptionIds)
{
UnsubscribeInternalAsync(id).Wait();
}
Logger.Error(ex, "Failed to subscribe to addresses");
throw;
}
}
}, ct);
}
/// <summary>
/// Unsubscribes from a subscription by its ID.
/// </summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <returns>
/// A <see cref="Task" /> representing the asynchronous operation.
/// </returns>
private Task UnsubscribeInternalAsync(string subscriptionId)
{
return Task.Run(() =>
{
lock (_lock)
{
if (!_subscriptions.TryGetValue(subscriptionId, out SubscriptionInfo? subscription))
{
return;
}
try
{
if (_lmxProxy != null && _connectionHandle > 0)
{
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
}
_subscriptions.Remove(subscriptionId);
_subscriptionsByHandle.Remove(subscription.ItemHandle);
Logger.Debug("Unsubscribed from {Address}", subscription.Address);
}
catch (Exception ex)
{
Logger.Warning(ex, "Error unsubscribing from {Address}", subscription.Address);
}
}
});
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Implementation of <see cref="IScadaClient" /> using ArchestrA MxAccess.
/// Provides connection management, read/write operations, and subscription support for SCADA tags.
/// </summary>
public sealed partial class MxAccessClient : IScadaClient
{
private const int DefaultMaxConcurrency = 10;
private static readonly ILogger Logger = Log.ForContext<MxAccessClient>();
private readonly ConnectionConfiguration _configuration;
private readonly object _lock = new();
private readonly Dictionary<int, WriteOperation> _pendingWrites = new();
// Concurrency control for batch operations
private readonly SemaphoreSlim _readSemaphore;
// Store subscription details for automatic recreation after reconnect
private readonly List<StoredSubscription> _storedSubscriptions = new();
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
private readonly Dictionary<int, SubscriptionInfo> _subscriptionsByHandle = new();
private readonly SemaphoreSlim _writeSemaphore;
private int _connectionHandle;
private ConnectionState _connectionState = ConnectionState.Disconnected;
private bool _disposed;
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Initializes a new instance of the <see cref="MxAccessClient" /> class.
/// </summary>
/// <param name="configuration">The connection configuration settings.</param>
public MxAccessClient(ConnectionConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
// Initialize semaphores with configurable concurrency limits
int maxConcurrency = _configuration.MaxConcurrentOperations ?? DefaultMaxConcurrency;
_readSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
_writeSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
}
/// <inheritdoc />
public bool IsConnected
{
get
{
lock (_lock)
{
return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0;
}
}
}
/// <inheritdoc />
public ConnectionState ConnectionState
{
get
{
lock (_lock)
{
return _connectionState;
}
}
}
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
await DisconnectAsync();
_disposed = true;
// Dispose semaphores
_readSemaphore?.Dispose();
_writeSemaphore?.Dispose();
}
/// <inheritdoc />
public void Dispose() => DisposeAsync().GetAwaiter().GetResult();
/// <summary>
/// Sets the connection state and raises the <see cref="ConnectionStateChanged" /> event.
/// </summary>
/// <param name="newState">The new connection state.</param>
/// <param name="message">Optional message describing the state change.</param>
private void SetConnectionState(ConnectionState newState, string? message = null)
{
ConnectionState previousState = _connectionState;
if (previousState == newState)
{
return;
}
_connectionState = newState;
Logger.Information("Connection state changed from {Previous} to {Current}", previousState, newState);
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previousState, newState, message));
}
/// <summary>
/// Removes a stored subscription group by its ID.
/// </summary>
/// <param name="groupId">The group identifier to remove.</param>
private void RemoveStoredSubscription(string groupId)
{
lock (_lock)
{
_storedSubscriptions.RemoveAll(s => s.GroupId == groupId);
Logger.Debug("Removed stored subscription group {GroupId}", groupId);
}
}
#pragma warning disable CS0169 // Field is never used - reserved for future functionality
private string? _currentNodeName;
private string? _currentGalaxyName;
#pragma warning restore CS0169
}
}

View File

@@ -0,0 +1,592 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
using ZB.MOM.WW.LmxProxy.Host.Implementation;
using ZB.MOM.WW.LmxProxy.Host.Security;
using ZB.MOM.WW.LmxProxy.Host.Services;
using ZB.MOM.WW.LmxProxy.Host.Grpc;
using ConnectionState = ZB.MOM.WW.LmxProxy.Host.Domain.ConnectionState;
namespace ZB.MOM.WW.LmxProxy.Host
{
/// <summary>
/// Windows service that hosts the gRPC server and MxAccess client.
/// Manages lifecycle of gRPC server, SCADA client, subscription manager, and API key service.
/// </summary>
public class LmxProxyService
{
private static readonly ILogger Logger = Log.ForContext<LmxProxyService>();
private readonly LmxProxyConfiguration _configuration;
private readonly SemaphoreSlim _reconnectSemaphore = new(1, 1);
private readonly Func<LmxProxyConfiguration, IScadaClient> _scadaClientFactory;
private readonly CancellationTokenSource _shutdownCts = new();
private ApiKeyService? _apiKeyService;
private Task? _connectionMonitorTask;
private DetailedHealthCheckService? _detailedHealthCheckService;
private Server? _grpcServer;
private HealthCheckService? _healthCheckService;
private PerformanceMetrics? _performanceMetrics;
private IScadaClient? _scadaClient;
private SessionManager? _sessionManager;
private StatusReportService? _statusReportService;
private StatusWebServer? _statusWebServer;
private SubscriptionManager? _subscriptionManager;
/// <summary>
/// Initializes a new instance of the <see cref="LmxProxyService" /> class.
/// </summary>
/// <param name="configuration">Configuration settings for the service.</param>
/// <exception cref="ArgumentNullException">Thrown if configuration is null.</exception>
public LmxProxyService(LmxProxyConfiguration configuration,
Func<LmxProxyConfiguration, IScadaClient>? scadaClientFactory = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_scadaClientFactory = scadaClientFactory ?? (config => new MxAccessClient(config.Connection));
}
/// <summary>
/// Starts the LmxProxy service, initializing all required components and starting the gRPC server.
/// </summary>
/// <returns><c>true</c> if the service started successfully; otherwise, <c>false</c>.</returns>
public bool Start()
{
try
{
Logger.Information("Starting LmxProxy service on port {Port}", _configuration.GrpcPort);
// Validate configuration before proceeding
if (!ValidateConfiguration())
{
Logger.Error("Configuration validation failed");
return false;
}
// Check and ensure TLS certificates are valid
if (_configuration.Tls.Enabled)
{
Logger.Information("Checking TLS certificate configuration");
var tlsManager = new TlsCertificateManager(_configuration.Tls);
if (!tlsManager.EnsureCertificatesValid())
{
Logger.Error("Failed to ensure valid TLS certificates");
throw new InvalidOperationException("TLS certificate validation or generation failed");
}
Logger.Information("TLS certificates validated successfully");
}
// Create performance metrics service
_performanceMetrics = new PerformanceMetrics();
Logger.Information("Performance metrics service initialized");
// Create API key service
string apiKeyConfigPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
_apiKeyService = new ApiKeyService(apiKeyConfigPath);
Logger.Information("API key service initialized with config file: {ConfigFile}", apiKeyConfigPath);
// Create SCADA client via factory
_scadaClient = _scadaClientFactory(_configuration) ??
throw new InvalidOperationException("SCADA client factory returned null.");
// Subscribe to connection state changes
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
// Automatically connect to MxAccess on startup
try
{
Logger.Information("Connecting to MxAccess...");
Task connectTask = _scadaClient.ConnectAsync();
if (!connectTask.Wait(TimeSpan.FromSeconds(_configuration.Connection.ConnectionTimeoutSeconds)))
{
throw new TimeoutException(
$"Timeout connecting to MxAccess after {_configuration.Connection.ConnectionTimeoutSeconds} seconds");
}
Logger.Information("Successfully connected to MxAccess");
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to connect to MxAccess on startup");
throw;
}
// Start connection monitoring if auto-reconnect is enabled
if (_configuration.Connection.AutoReconnect)
{
_connectionMonitorTask = Task.Run(() => MonitorConnectionAsync(_shutdownCts.Token));
Logger.Information("Connection monitoring started with {Interval} second interval",
_configuration.Connection.MonitorIntervalSeconds);
}
// Create subscription manager with configuration
_subscriptionManager = new SubscriptionManager(_scadaClient, _configuration.Subscription);
// Create session manager for tracking client sessions
_sessionManager = new SessionManager();
Logger.Information("Session manager initialized");
// Create health check services
_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics);
_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient);
Logger.Information("Health check services initialized");
// Create status report service and web server
_statusReportService = new StatusReportService(
_scadaClient,
_subscriptionManager,
_performanceMetrics,
_healthCheckService,
_detailedHealthCheckService);
_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService);
Logger.Information("Status web server initialized");
// Create gRPC service with session manager and performance metrics
var scadaService = new ScadaGrpcService(_scadaClient, _subscriptionManager, _sessionManager, _performanceMetrics);
// Create API key interceptor
var apiKeyInterceptor = new ApiKeyInterceptor(_apiKeyService);
// Configure server credentials based on TLS configuration
ServerCredentials serverCredentials;
if (_configuration.Tls.Enabled)
{
serverCredentials = CreateTlsCredentials(_configuration.Tls);
Logger.Information("TLS enabled for gRPC server");
}
else
{
serverCredentials = ServerCredentials.Insecure;
Logger.Warning("gRPC server running without TLS encryption - not recommended for production");
}
// Configure and start gRPC server with interceptor
_grpcServer = new Server
{
Services = { ScadaService.BindService(scadaService).Intercept(apiKeyInterceptor) },
Ports = { new ServerPort("0.0.0.0", _configuration.GrpcPort, serverCredentials) }
};
_grpcServer.Start();
string securityMode = _configuration.Tls.Enabled ? "TLS/SSL" : "INSECURE";
Logger.Information("LmxProxy service started successfully on port {Port} ({SecurityMode})",
_configuration.GrpcPort, securityMode);
Logger.Information("gRPC server listening on 0.0.0.0:{Port}", _configuration.GrpcPort);
// Start status web server
if (_statusWebServer != null && !_statusWebServer.Start())
{
Logger.Warning("Failed to start status web server, continuing without it");
}
return true;
}
catch (Exception ex)
{
Logger.Fatal(ex, "Failed to start LmxProxy service");
return false;
}
}
/// <summary>
/// Stops the LmxProxy service, shutting down the gRPC server and disposing all resources.
/// </summary>
/// <returns><c>true</c> if the service stopped successfully; otherwise, <c>false</c>.</returns>
public bool Stop()
{
try
{
Logger.Information("Stopping LmxProxy service");
_shutdownCts.Cancel();
// Stop connection monitoring
if (_connectionMonitorTask != null)
{
try
{
_connectionMonitorTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Logger.Warning(ex, "Error stopping connection monitor");
}
}
// Shutdown gRPC server
if (_grpcServer != null)
{
Logger.Information("Shutting down gRPC server");
Task? shutdownTask = _grpcServer.ShutdownAsync();
// Wait up to 10 seconds for graceful shutdown
if (!shutdownTask.Wait(TimeSpan.FromSeconds(10)))
{
Logger.Warning("gRPC server shutdown timeout, forcing kill");
_grpcServer.KillAsync().Wait(TimeSpan.FromSeconds(5));
}
_grpcServer = null;
}
// Stop status web server
if (_statusWebServer != null)
{
Logger.Information("Stopping status web server");
try
{
_statusWebServer.Stop();
_statusWebServer.Dispose();
_statusWebServer = null;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error stopping status web server");
}
}
// Dispose status report service
if (_statusReportService != null)
{
Logger.Information("Disposing status report service");
_statusReportService = null;
}
// Dispose health check services
if (_detailedHealthCheckService != null)
{
Logger.Information("Disposing detailed health check service");
_detailedHealthCheckService = null;
}
if (_healthCheckService != null)
{
Logger.Information("Disposing health check service");
_healthCheckService = null;
}
// Dispose subscription manager
if (_subscriptionManager != null)
{
Logger.Information("Disposing subscription manager");
_subscriptionManager.Dispose();
_subscriptionManager = null;
}
// Dispose session manager
if (_sessionManager != null)
{
Logger.Information("Disposing session manager");
_sessionManager.Dispose();
_sessionManager = null;
}
// Dispose API key service
if (_apiKeyService != null)
{
Logger.Information("Disposing API key service");
_apiKeyService.Dispose();
_apiKeyService = null;
}
// Dispose performance metrics
if (_performanceMetrics != null)
{
Logger.Information("Disposing performance metrics service");
_performanceMetrics.Dispose();
_performanceMetrics = null;
}
// Disconnect and dispose SCADA client
if (_scadaClient != null)
{
Logger.Information("Disconnecting SCADA client");
// Unsubscribe from events
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
try
{
Task disconnectTask = _scadaClient.DisconnectAsync();
if (!disconnectTask.Wait(TimeSpan.FromSeconds(10)))
{
Logger.Warning("SCADA client disconnect timeout");
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error disconnecting SCADA client");
}
try
{
Task? disposeTask = _scadaClient.DisposeAsync().AsTask();
if (!disposeTask.Wait(TimeSpan.FromSeconds(5)))
{
Logger.Warning("SCADA client dispose timeout");
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error disposing SCADA client");
}
_scadaClient = null;
}
Logger.Information("LmxProxy service stopped successfully");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping LmxProxy service");
return false;
}
}
/// <summary>
/// Pauses the LmxProxy service. No operation is performed except logging.
/// </summary>
public void Pause() => Logger.Information("LmxProxy service paused");
/// <summary>
/// Continues the LmxProxy service after a pause. No operation is performed except logging.
/// </summary>
public void Continue() => Logger.Information("LmxProxy service continued");
/// <summary>
/// Requests shutdown of the LmxProxy service and stops all components.
/// </summary>
public void Shutdown()
{
Logger.Information("LmxProxy service shutdown requested");
Stop();
}
/// <summary>
/// Handles connection state changes from the SCADA client.
/// </summary>
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Logger.Information("MxAccess connection state changed from {Previous} to {Current}",
e.PreviousState, e.CurrentState);
if (e.CurrentState == ConnectionState.Disconnected &&
e.PreviousState == ConnectionState.Connected)
{
Logger.Warning("MxAccess connection lost. Automatic reconnection will be attempted.");
}
}
/// <summary>
/// Monitors the connection and attempts to reconnect when disconnected.
/// </summary>
private async Task MonitorConnectionAsync(CancellationToken cancellationToken)
{
Logger.Information("Starting connection monitor");
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_configuration.Connection.MonitorIntervalSeconds),
cancellationToken);
if (_scadaClient != null && !_scadaClient.IsConnected && !cancellationToken.IsCancellationRequested)
{
await _reconnectSemaphore.WaitAsync(cancellationToken);
try
{
if (_scadaClient != null && !_scadaClient.IsConnected)
{
Logger.Information("Attempting to reconnect to MxAccess...");
try
{
await _scadaClient.ConnectAsync(cancellationToken);
Logger.Information("Successfully reconnected to MxAccess");
}
catch (Exception ex)
{
Logger.Warning(ex,
"Failed to reconnect to MxAccess. Will retry in {Interval} seconds.",
_configuration.Connection.MonitorIntervalSeconds);
}
}
}
finally
{
_reconnectSemaphore.Release();
}
}
}
catch (OperationCanceledException)
{
// Expected when shutting down
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error in connection monitor");
}
}
Logger.Information("Connection monitor stopped");
}
/// <summary>
/// Creates TLS server credentials from configuration
/// </summary>
private static ServerCredentials CreateTlsCredentials(TlsConfiguration tlsConfig)
{
try
{
// Read certificate and key files
string serverCert = File.ReadAllText(tlsConfig.ServerCertificatePath);
string serverKey = File.ReadAllText(tlsConfig.ServerKeyPath);
var keyCertPairs = new List<KeyCertificatePair>
{
new(serverCert, serverKey)
};
// Configure client certificate requirements
if (tlsConfig.RequireClientCertificate && !string.IsNullOrWhiteSpace(tlsConfig.ClientCaCertificatePath))
{
string clientCaCert = File.ReadAllText(tlsConfig.ClientCaCertificatePath);
return new SslServerCredentials(
keyCertPairs,
clientCaCert,
tlsConfig.CheckCertificateRevocation
? SslClientCertificateRequestType.RequestAndRequireAndVerify
: SslClientCertificateRequestType.RequestAndRequireButDontVerify);
}
if (tlsConfig.RequireClientCertificate)
{
// Require client certificate but no CA specified - use system CA
return new SslServerCredentials(
keyCertPairs,
null,
SslClientCertificateRequestType.RequestAndRequireAndVerify);
}
// No client certificate required
return new SslServerCredentials(keyCertPairs);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create TLS credentials");
throw new InvalidOperationException("Failed to configure TLS for gRPC server", ex);
}
}
/// <summary>
/// Validates the service configuration and returns false if any critical issues are found
/// </summary>
private bool ValidateConfiguration()
{
try
{
// Validate gRPC port
if (_configuration.GrpcPort <= 0 || _configuration.GrpcPort > 65535)
{
Logger.Error("Invalid gRPC port: {Port}. Port must be between 1 and 65535",
_configuration.GrpcPort);
return false;
}
// Validate API key configuration file
if (string.IsNullOrWhiteSpace(_configuration.ApiKeyConfigFile))
{
Logger.Error("API key configuration file path is null or empty");
return false;
}
// Check if API key file exists or can be created
string apiKeyPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
string? apiKeyDirectory = Path.GetDirectoryName(apiKeyPath);
if (!string.IsNullOrEmpty(apiKeyDirectory) && !Directory.Exists(apiKeyDirectory))
{
try
{
Directory.CreateDirectory(apiKeyDirectory);
}
catch (Exception ex)
{
Logger.Error(ex, "Cannot create directory for API key file: {Directory}", apiKeyDirectory);
return false;
}
}
// If API key file exists, validate it can be read
if (File.Exists(apiKeyPath))
{
try
{
string content = File.ReadAllText(apiKeyPath);
if (!string.IsNullOrWhiteSpace(content))
{
// Try to parse as JSON to validate format
JsonDocument.Parse(content);
}
}
catch (Exception ex)
{
Logger.Error(ex, "API key configuration file is invalid or unreadable: {FilePath}", apiKeyPath);
return false;
}
}
// Validate TLS configuration if enabled
if (_configuration.Tls.Enabled)
{
if (!_configuration.Tls.Validate())
{
Logger.Error("TLS configuration validation failed");
return false;
}
}
// Validate web server configuration if enabled
if (_configuration.WebServer.Enabled)
{
if (_configuration.WebServer.Port <= 0 || _configuration.WebServer.Port > 65535)
{
Logger.Error("Invalid web server port: {Port}. Port must be between 1 and 65535",
_configuration.WebServer.Port);
return false;
}
// Check for port conflicts
if (_configuration.WebServer.Port == _configuration.GrpcPort)
{
Logger.Error("Web server port {WebPort} conflicts with gRPC port {GrpcPort}",
_configuration.WebServer.Port, _configuration.GrpcPort);
return false;
}
}
Logger.Information("Configuration validation passed");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error during configuration validation");
return false;
}
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
using Microsoft.Extensions.Configuration;
using Serilog;
using Topshelf;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host
{
internal class Program
{
private static void Main(string[] args)
{
// Build configuration
IConfigurationRoot? configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", true, true)
.AddEnvironmentVariables()
.Build();
// Configure Serilog from appsettings.json
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
try
{
Log.Information("Starting ZB.MOM.WW.LmxProxy.Host");
// Load configuration
var config = new LmxProxyConfiguration();
configuration.Bind(config);
// Validate configuration
if (!ConfigurationValidator.ValidateAndLog(config))
{
Log.Fatal("Configuration validation failed. Please check the configuration and try again.");
Environment.ExitCode = 1;
return;
}
// Configure and run the Windows service using TopShelf
TopshelfExitCode exitCode = HostFactory.Run(hostConfig =>
{
hostConfig.Service<LmxProxyService>(serviceConfig =>
{
serviceConfig.ConstructUsing(() => new LmxProxyService(config));
serviceConfig.WhenStarted(service => service.Start());
serviceConfig.WhenStopped(service => service.Stop());
serviceConfig.WhenPaused(service => service.Pause());
serviceConfig.WhenContinued(service => service.Continue());
serviceConfig.WhenShutdown(service => service.Shutdown());
});
hostConfig.UseSerilog(Log.Logger);
hostConfig.SetServiceName("ZB.MOM.WW.LmxProxy.Host");
hostConfig.SetDisplayName("SCADA Bridge LMX Proxy");
hostConfig.SetDescription("Provides gRPC access to Archestra MxAccess for SCADA Bridge");
hostConfig.StartAutomatically();
hostConfig.EnableServiceRecovery(recoveryConfig =>
{
recoveryConfig.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes);
recoveryConfig.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes);
recoveryConfig.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes);
recoveryConfig.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays);
});
hostConfig.OnException(ex => { Log.Fatal(ex, "Unhandled exception in service"); });
});
Log.Information("Service exited with code: {ExitCode}", exitCode);
Environment.ExitCode = (int)exitCode;
}
catch (Exception ex)
{
Log.Fatal(ex, "Failed to start service");
Environment.ExitCode = 1;
}
finally
{
Log.CloseAndFlush();
}
}
}
}

View File

@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Represents an API key with associated permissions
/// </summary>
public class ApiKey
{
/// <summary>
/// The API key value
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Description of what this API key is used for
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The role assigned to this API key
/// </summary>
public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
/// <summary>
/// Whether this API key is enabled
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Checks if the API key is valid
/// </summary>
public bool IsValid() => Enabled;
}
/// <summary>
/// API key roles
/// </summary>
public enum ApiKeyRole
{
/// <summary>
/// Can only read data
/// </summary>
ReadOnly,
/// <summary>
/// Can read and write data
/// </summary>
ReadWrite
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Configuration for API keys loaded from file
/// </summary>
public class ApiKeyConfiguration
{
/// <summary>
/// List of API keys
/// </summary>
public List<ApiKey> ApiKeys { get; set; } = new();
}
}

View File

@@ -0,0 +1,168 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// gRPC interceptor for API key authentication.
/// Validates API keys for incoming requests and enforces role-based access control.
/// </summary>
public class ApiKeyInterceptor : Interceptor
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyInterceptor>();
/// <summary>
/// List of gRPC method names that require write access.
/// </summary>
private static readonly string[] WriteMethodNames =
{
"Write",
"WriteBatch",
"WriteBatchAndWait"
};
private readonly ApiKeyService _apiKeyService;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyInterceptor" /> class.
/// </summary>
/// <param name="apiKeyService">The API key service used for validation.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="apiKeyService" /> is null.</exception>
public ApiKeyInterceptor(ApiKeyService apiKeyService)
{
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
}
/// <summary>
/// Handles unary gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>The response message.</returns>
/// <exception cref="RpcException">Thrown if authentication or authorization fails.</exception>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
string methodName = GetMethodName(context.Method);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Check if method requires write access
if (IsWriteMethod(methodName) && key.Role != ApiKeyRole.ReadWrite)
{
Logger.Warning("Insufficient permissions for method {Method} with API key {Description}",
context.Method, key.Description);
throw new RpcException(new Status(StatusCode.PermissionDenied,
"API key does not have write permissions"));
}
// Add API key info to context items for use in service methods
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized method {Method} for API key {Description}",
context.Method, key.Description);
return await continuation(request, context);
}
/// <summary>
/// Handles server streaming gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="responseStream">The response stream writer.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="RpcException">Thrown if authentication fails.</exception>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Add API key info to context items
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized streaming method {Method} for API key {Description}",
context.Method, key.Description);
await continuation(request, responseStream, context);
}
/// <summary>
/// Extracts the API key from the gRPC request headers.
/// </summary>
/// <param name="context">The server call context.</param>
/// <returns>The API key value, or an empty string if not found.</returns>
private static string GetApiKeyFromContext(ServerCallContext context)
{
// Check for API key in metadata (headers)
Metadata.Entry? entry = context.RequestHeaders.FirstOrDefault(e =>
e.Key.Equals("x-api-key", StringComparison.OrdinalIgnoreCase));
return entry?.Value ?? string.Empty;
}
/// <summary>
/// Gets the method name from the full gRPC method string.
/// </summary>
/// <param name="method">The full method string (e.g., /package.Service/Method).</param>
/// <returns>The method name.</returns>
private static string GetMethodName(string method)
{
// Method format is /package.Service/Method
int lastSlash = method.LastIndexOf('/');
return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method;
}
/// <summary>
/// Determines whether the specified method name requires write access.
/// </summary>
/// <param name="methodName">The method name.</param>
/// <returns><c>true</c> if the method requires write access; otherwise, <c>false</c>.</returns>
private static bool IsWriteMethod(string methodName) =>
WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Service for managing API keys with file-based storage.
/// Handles validation, role checking, and automatic reload on file changes.
/// </summary>
public class ApiKeyService : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyService>();
private readonly ConcurrentDictionary<string, ApiKey> _apiKeys;
private readonly string _configFilePath;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
private bool _disposed;
private FileSystemWatcher? _fileWatcher;
private DateTime _lastReloadTime = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyService" /> class.
/// </summary>
/// <param name="configFilePath">The path to the API key configuration file.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="configFilePath" /> is null.</exception>
public ApiKeyService(string configFilePath)
{
_configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath));
_apiKeys = new ConcurrentDictionary<string, ApiKey>();
InitializeFileWatcher();
LoadConfiguration();
}
/// <summary>
/// Disposes the <see cref="ApiKeyService" /> and releases resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_fileWatcher?.Dispose();
_reloadLock?.Dispose();
Logger.Information("API key service disposed");
}
/// <summary>
/// Validates an API key and returns its details if valid.
/// </summary>
/// <param name="apiKey">The API key value to validate.</param>
/// <returns>The <see cref="ApiKey" /> if valid; otherwise, <c>null</c>.</returns>
public ApiKey? ValidateApiKey(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
{
return null;
}
if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid())
{
Logger.Debug("API key validated successfully for {Description}", key.Description);
return key;
}
Logger.Warning("Invalid or expired API key attempted");
return null;
}
/// <summary>
/// Checks if an API key has the specified role.
/// </summary>
/// <param name="apiKey">The API key value.</param>
/// <param name="requiredRole">The required <see cref="ApiKeyRole" />.</param>
/// <returns><c>true</c> if the API key has the required role; otherwise, <c>false</c>.</returns>
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
{
ApiKey? key = ValidateApiKey(apiKey);
if (key == null)
{
return false;
}
// ReadWrite role has access to everything
if (key.Role == ApiKeyRole.ReadWrite)
{
return true;
}
// ReadOnly role only has access to ReadOnly operations
return requiredRole == ApiKeyRole.ReadOnly;
}
/// <summary>
/// Initializes the file system watcher for the API key configuration file.
/// </summary>
private void InitializeFileWatcher()
{
string? directory = Path.GetDirectoryName(_configFilePath);
string? fileName = Path.GetFileName(_configFilePath);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
{
Logger.Warning("Invalid config file path, file watching disabled");
return;
}
try
{
_fileWatcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
EnableRaisingEvents = true
};
_fileWatcher.Changed += OnFileChanged;
_fileWatcher.Created += OnFileChanged;
_fileWatcher.Renamed += OnFileRenamed;
Logger.Information("File watcher initialized for {FilePath}", _configFilePath);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath);
}
}
/// <summary>
/// Handles file change events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing event data.</param>
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created)
{
Logger.Information("API key configuration file changed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Handles file rename events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="RenamedEventArgs" /> instance containing event data.</param>
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase))
{
Logger.Information("API key configuration file renamed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Asynchronously reloads the API key configuration from file.
/// Debounces rapid file changes to avoid excessive reloads.
/// </summary>
private async Task ReloadConfigurationAsync()
{
// Debounce rapid file changes
TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime;
if (timeSinceLastReload < TimeSpan.FromSeconds(1))
{
await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload);
}
await _reloadLock.WaitAsync();
try
{
LoadConfiguration();
_lastReloadTime = DateTime.UtcNow;
}
finally
{
_reloadLock.Release();
}
}
/// <summary>
/// Loads the API key configuration from file.
/// If the file does not exist, creates a default configuration.
/// </summary>
private void LoadConfiguration()
{
try
{
if (!File.Exists(_configFilePath))
{
Logger.Warning("API key configuration file not found at {FilePath}, creating default",
_configFilePath);
CreateDefaultConfiguration();
return;
}
string json = File.ReadAllText(_configFilePath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
options.Converters.Add(new JsonStringEnumConverter());
ApiKeyConfiguration? config = JsonSerializer.Deserialize<ApiKeyConfiguration>(json, options);
if (config?.ApiKeys == null || !config.ApiKeys.Any())
{
Logger.Warning("No API keys found in configuration file");
return;
}
// Clear existing keys and load new ones
_apiKeys.Clear();
foreach (ApiKey? apiKey in config.ApiKeys)
{
if (string.IsNullOrWhiteSpace(apiKey.Key))
{
Logger.Warning("Skipping API key with empty key value");
continue;
}
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
{
Logger.Information("Loaded API key: {Description} with role {Role}",
apiKey.Description, apiKey.Role);
}
else
{
Logger.Warning("Duplicate API key found: {Description}", apiKey.Description);
}
}
Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath);
}
}
/// <summary>
/// Creates a default API key configuration file with sample keys.
/// </summary>
private void CreateDefaultConfiguration()
{
try
{
var defaultConfig = new ApiKeyConfiguration
{
ApiKeys = new List<ApiKey>
{
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-only API key",
Role = ApiKeyRole.ReadOnly,
Enabled = true
},
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-write API key",
Role = ApiKeyRole.ReadWrite,
Enabled = true
}
}
};
string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions
{
WriteIndented = true
});
string? directory = Path.GetDirectoryName(_configFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_configFilePath, json);
Logger.Information("Created default API key configuration at {FilePath}", _configFilePath);
// Load the created configuration
LoadConfiguration();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create default API key configuration");
}
}
}
}

View File

@@ -0,0 +1,329 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Manages TLS certificates for the LmxProxy service, including generation and validation
/// </summary>
public class TlsCertificateManager
{
private static readonly ILogger Logger = Log.ForContext<TlsCertificateManager>();
private readonly TlsConfiguration _tlsConfiguration;
public TlsCertificateManager(TlsConfiguration tlsConfiguration)
{
_tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
}
/// <summary>
/// Checks TLS certificate status and creates new certificates if needed
/// </summary>
/// <returns>True if certificates are valid or were successfully created</returns>
public bool EnsureCertificatesValid()
{
if (!_tlsConfiguration.Enabled)
{
Logger.Information("TLS is disabled, skipping certificate check");
return true;
}
try
{
// Check if certificate files exist
bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath);
bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath);
if (!certificateExists || !keyExists)
{
Logger.Warning("TLS certificate or key not found, generating new certificate");
return GenerateNewCertificate();
}
// Check certificate expiration
if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath))
{
Logger.Warning("TLS certificate is expiring within the next year, generating new certificate");
return GenerateNewCertificate();
}
Logger.Information("TLS certificate is valid");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking TLS certificates");
return false;
}
}
/// <summary>
/// Checks if a certificate is expiring within the next year
/// </summary>
private bool IsCertificateExpiringSoon(string certificatePath)
{
try
{
string certPem = File.ReadAllText(certificatePath);
byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE");
using var cert = new X509Certificate2(certBytes);
DateTime expirationDate = cert.NotAfter;
double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays;
Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)",
expirationDate, daysUntilExpiration);
// Check if expiring within the next year (365 days)
return daysUntilExpiration <= 365;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking certificate expiration");
// If we can't check expiration, assume it needs renewal
return true;
}
}
/// <summary>
/// Generates a new self-signed certificate
/// </summary>
private bool GenerateNewCertificate()
{
try
{
Logger.Information("Generating new self-signed TLS certificate");
// Ensure directory exists
string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath);
if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir))
{
Directory.CreateDirectory(certDir);
Logger.Information("Created certificate directory: {Directory}", certDir);
}
// Generate a new self-signed certificate
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=LmxProxy, O=SCADA Bridge, C=US",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add certificate extensions
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
false));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication
},
false));
// Add Subject Alternative Names
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("localhost");
sanBuilder.AddDnsName(Environment.MachineName);
sanBuilder.AddIpAddress(IPAddress.Loopback);
sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
request.CertificateExtensions.Add(sanBuilder.Build());
// Create the certificate with 2-year validity
DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1);
DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2);
using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter);
// Export certificate to PEM format
string certPem = ExportCertificateToPem(cert);
File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem);
Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath);
// Export private key to PEM format
string keyPem = ExportPrivateKeyToPem(rsa);
File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem);
Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath);
// If client CA path is specified and doesn't exist, create it
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCaCertificatePath))
{
// For self-signed certificates, the CA cert is the same as the server cert
File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem);
Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath);
}
Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to generate new TLS certificate");
return false;
}
}
/// <summary>
/// Exports a certificate to PEM format
/// </summary>
private static string ExportCertificateToPem(X509Certificate2 cert)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert),
Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
/// <summary>
/// Exports an RSA private key to PEM format
/// </summary>
private static string ExportPrivateKeyToPem(RSA rsa)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----");
// For .NET Framework 4.8, we need to use the older export method
RSAParameters parameters = rsa.ExportParameters(true);
byte[] keyBytes = EncodeRSAPrivateKey(parameters);
builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END RSA PRIVATE KEY-----");
return builder.ToString();
}
/// <summary>
/// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8
/// </summary>
private static byte[] EncodeRSAPrivateKey(RSAParameters parameters)
{
using (var stream = new MemoryStream())
using (var writer = new BinaryWriter(stream))
{
// Write version
writer.Write((byte)0x02); // INTEGER
writer.Write((byte)0x01); // Length
writer.Write((byte)0x00); // Version
// Write modulus
WriteIntegerBytes(writer, parameters.Modulus);
// Write public exponent
WriteIntegerBytes(writer, parameters.Exponent);
// Write private exponent
WriteIntegerBytes(writer, parameters.D);
// Write prime1
WriteIntegerBytes(writer, parameters.P);
// Write prime2
WriteIntegerBytes(writer, parameters.Q);
// Write exponent1
WriteIntegerBytes(writer, parameters.DP);
// Write exponent2
WriteIntegerBytes(writer, parameters.DQ);
// Write coefficient
WriteIntegerBytes(writer, parameters.InverseQ);
byte[] innerBytes = stream.ToArray();
// Create SEQUENCE wrapper
using (var finalStream = new MemoryStream())
using (var finalWriter = new BinaryWriter(finalStream))
{
finalWriter.Write((byte)0x30); // SEQUENCE
WriteLength(finalWriter, innerBytes.Length);
finalWriter.Write(innerBytes);
return finalStream.ToArray();
}
}
}
private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes)
{
if (bytes == null)
{
bytes = new byte[] { 0 };
}
writer.Write((byte)0x02); // INTEGER
if (bytes[0] >= 0x80)
{
// Add padding byte for positive number
WriteLength(writer, bytes.Length + 1);
writer.Write((byte)0x00);
writer.Write(bytes);
}
else
{
WriteLength(writer, bytes.Length);
writer.Write(bytes);
}
}
private static void WriteLength(BinaryWriter writer, int length)
{
if (length < 0x80)
{
writer.Write((byte)length);
}
else if (length <= 0xFF)
{
writer.Write((byte)0x81);
writer.Write((byte)length);
}
else
{
writer.Write((byte)0x82);
writer.Write((byte)(length >> 8));
writer.Write((byte)(length & 0xFF));
}
}
/// <summary>
/// Extracts bytes from PEM format
/// </summary>
private static byte[] GetBytesFromPem(string pem, string section)
{
string header = $"-----BEGIN {section}-----";
string footer = $"-----END {section}-----";
int start = pem.IndexOf(header, StringComparison.Ordinal);
if (start < 0)
{
throw new InvalidOperationException($"PEM {section} header not found");
}
start += header.Length;
int end = pem.IndexOf(footer, start, StringComparison.Ordinal);
if (end < 0)
{
throw new InvalidOperationException($"PEM {section} footer not found");
}
// Use Substring instead of range syntax for .NET Framework 4.8 compatibility
string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", "");
return Convert.FromBase64String(base64);
}
}
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Health check service for monitoring LmxProxy health
/// </summary>
public class HealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
public HealthCheckService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Check SCADA connection
bool isConnected = _scadaClient.IsConnected;
ConnectionState connectionState = _scadaClient.ConnectionState;
data["scada_connected"] = isConnected;
data["scada_connection_state"] = connectionState.ToString();
// Get subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
data["total_clients"] = subscriptionStats.TotalClients;
data["total_tags"] = subscriptionStats.TotalTags;
// Get performance metrics
IReadOnlyDictionary<string, OperationMetrics> metrics = _performanceMetrics.GetAllMetrics();
long totalOperations = 0L;
double averageSuccessRate = 0.0;
foreach (OperationMetrics? metric in metrics.Values)
{
MetricsStatistics stats = metric.GetStatistics();
totalOperations += stats.TotalCount;
averageSuccessRate += stats.SuccessRate;
}
if (metrics.Count > 0)
{
averageSuccessRate /= metrics.Count;
}
data["total_operations"] = totalOperations;
data["average_success_rate"] = averageSuccessRate;
// Determine health status
if (!isConnected)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
"SCADA client is not connected",
data: data));
}
if (averageSuccessRate < 0.5 && totalOperations > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"Low success rate: {averageSuccessRate:P}",
data: data));
}
if (subscriptionStats.TotalClients > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"High client count: {subscriptionStats.TotalClients}",
data: data));
}
return Task.FromResult(HealthCheckResult.Healthy(
"LmxProxy is healthy",
data));
}
catch (Exception ex)
{
Logger.Error(ex, "Health check failed");
data["error"] = ex.Message;
return Task.FromResult(HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data));
}
}
}
/// <summary>
/// Detailed health check that performs additional connectivity tests
/// </summary>
public class DetailedHealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<DetailedHealthCheckService>();
private readonly IScadaClient _scadaClient;
private readonly string _testTagAddress;
public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "System.Heartbeat")
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_testTagAddress = testTagAddress;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Basic connectivity check
if (!_scadaClient.IsConnected)
{
data["connected"] = false;
return HealthCheckResult.Unhealthy("SCADA client is not connected", data: data);
}
data["connected"] = true;
// Try to read a test tag
try
{
Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken);
data["test_tag_quality"] = vtq.Quality.ToString();
data["test_tag_timestamp"] = vtq.Timestamp;
if (vtq.Quality != Quality.Good)
{
return HealthCheckResult.Degraded(
$"Test tag quality is {vtq.Quality}",
data: data);
}
// Check if timestamp is recent (within last 5 minutes)
TimeSpan age = DateTime.UtcNow - vtq.Timestamp;
if (age > TimeSpan.FromMinutes(5))
{
data["timestamp_age_minutes"] = age.TotalMinutes;
return HealthCheckResult.Degraded(
$"Test tag timestamp is stale ({age.TotalMinutes:F1} minutes old)",
data: data);
}
}
catch (Exception readEx)
{
data["test_tag_error"] = readEx.Message;
return HealthCheckResult.Degraded(
"Could not read test tag",
data: data);
}
return HealthCheckResult.Healthy("All checks passed", data);
}
catch (Exception ex)
{
Logger.Error(ex, "Detailed health check failed");
data["error"] = ex.Message;
return HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data);
}
}
}
}

View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides performance metrics tracking for LmxProxy operations
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new();
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the PerformanceMetrics class
/// </summary>
public PerformanceMetrics()
{
// Report metrics every minute
_reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_reportingTimer?.Dispose();
ReportMetrics(null); // Final report
}
/// <summary>
/// Records the execution time of an operation
/// </summary>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Creates a timing scope for measuring operation duration
/// </summary>
public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName);
/// <summary>
/// Gets current metrics for a specific operation
/// </summary>
public OperationMetrics? GetMetrics(string operationName) =>
_metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null;
/// <summary>
/// Gets all current metrics
/// </summary>
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
/// <summary>
/// Gets statistics for all operations
/// </summary>
public Dictionary<string, MetricsStatistics> GetStatistics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics());
private void ReportMetrics(object? state)
{
foreach (KeyValuePair<string, OperationMetrics> kvp in _metrics)
{
MetricsStatistics stats = kvp.Value.GetStatistics();
if (stats.TotalCount > 0)
{
Logger.Information(
"Performance Metrics - {Operation}: Count={Count}, Success={SuccessRate:P}, " +
"Avg={AverageMs:F2}ms, Min={MinMs:F2}ms, Max={MaxMs:F2}ms, P95={P95Ms:F2}ms",
kvp.Key,
stats.TotalCount,
stats.SuccessRate,
stats.AverageMilliseconds,
stats.MinMilliseconds,
stats.MaxMilliseconds,
stats.Percentile95Milliseconds);
}
}
}
/// <summary>
/// Timing scope for automatic duration measurement
/// </summary>
public interface ITimingScope : IDisposable
{
void SetSuccess(bool success);
}
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _disposed;
private bool _success = true;
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
public void SetSuccess(bool success) => _success = success;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
/// <summary>
/// Metrics for a specific operation
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
{
double ms = duration.TotalMilliseconds;
_durations.Add(ms);
_totalCount++;
if (success)
{
_successCount++;
}
_totalMilliseconds += ms;
_minMilliseconds = Math.Min(_minMilliseconds, ms);
_maxMilliseconds = Math.Max(_maxMilliseconds, ms);
// Keep only last 1000 samples for percentile calculation
if (_durations.Count > 1000)
{
_durations.RemoveAt(0);
}
}
}
public MetricsStatistics GetStatistics()
{
lock (_lock)
{
if (_totalCount == 0)
{
return new MetricsStatistics();
}
var sortedDurations = _durations.OrderBy(d => d).ToList();
int p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
return new MetricsStatistics
{
TotalCount = _totalCount,
SuccessCount = _successCount,
SuccessRate = _successCount / (double)_totalCount,
AverageMilliseconds = _totalMilliseconds / _totalCount,
MinMilliseconds = _minMilliseconds == double.MaxValue ? 0 : _minMilliseconds,
MaxMilliseconds = _maxMilliseconds,
Percentile95Milliseconds = sortedDurations.Count > 0 ? sortedDurations[Math.Max(0, p95Index)] : 0
};
}
}
}
/// <summary>
/// Statistics for an operation
/// </summary>
public class MetricsStatistics
{
public long TotalCount { get; set; }
public long SuccessCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
public double Percentile95Milliseconds { get; set; }
}
}

View File

@@ -0,0 +1,193 @@
using System;
using System.Threading.Tasks;
using Polly;
using Polly.Timeout;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides retry policies for resilient operations
/// </summary>
public static class RetryPolicies
{
private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies));
/// <summary>
/// Creates a retry policy with exponential backoff for read operations
/// </summary>
public static IAsyncPolicy<T> CreateReadPolicy<T>()
{
return Policy<T>
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)),
(outcome, timespan, retryCount, context) =>
{
Exception? exception = outcome.Exception;
Logger.Warning(exception,
"Read operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy with exponential backoff for write operations
/// </summary>
public static IAsyncPolicy CreateWritePolicy()
{
return Policy
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Write operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy for connection operations with longer delays
/// </summary>
public static IAsyncPolicy CreateConnectionPolicy()
{
return Policy
.Handle<Exception>()
.WaitAndRetryAsync(
5,
retryAttempt =>
{
// 2s, 4s, 8s, 16s, 32s
var delay = TimeSpan.FromSeconds(Math.Min(32, Math.Pow(2, retryAttempt)));
return delay;
},
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Connection retry {RetryCount} after {DelayMs}ms",
retryCount,
timespan.TotalMilliseconds);
});
}
/// <summary>
/// Creates a circuit breaker policy for protecting against repeated failures
/// </summary>
public static IAsyncPolicy<T> CreateCircuitBreakerPolicy<T>()
{
return Policy<T>
.Handle<Exception>()
.CircuitBreakerAsync(
5,
TimeSpan.FromSeconds(30),
(result, timespan) =>
{
Logger.Error(result.Exception,
"Circuit breaker opened for {BreakDurationSeconds}s due to repeated failures",
timespan.TotalSeconds);
},
() => { Logger.Information("Circuit breaker reset - resuming normal operations"); },
() => { Logger.Information("Circuit breaker half-open - testing operation"); });
}
/// <summary>
/// Creates a combined policy with retry and circuit breaker
/// </summary>
public static IAsyncPolicy<T> CreateCombinedPolicy<T>()
{
IAsyncPolicy<T> retry = CreateReadPolicy<T>();
IAsyncPolicy<T> circuitBreaker = CreateCircuitBreakerPolicy<T>();
// Wrap retry around circuit breaker
// This means retry happens first, and if all retries fail, it counts toward the circuit breaker
return Policy.WrapAsync(retry, circuitBreaker);
}
/// <summary>
/// Creates a timeout policy for operations
/// </summary>
public static IAsyncPolicy CreateTimeoutPolicy(TimeSpan timeout)
{
return Policy
.TimeoutAsync(
timeout,
TimeoutStrategy.Pessimistic,
async (context, timespan, task) =>
{
Logger.Warning(
"Operation timed out after {TimeoutMs}ms. Operation: {Operation}",
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
if (task != null)
{
try
{
await task;
}
catch
{
// Ignore exceptions from the timed-out task
}
}
});
}
/// <summary>
/// Creates a bulkhead policy to limit concurrent operations
/// </summary>
public static IAsyncPolicy CreateBulkheadPolicy(int maxParallelization, int maxQueuingActions = 100)
{
return Policy
.BulkheadAsync(
maxParallelization,
maxQueuingActions,
context =>
{
Logger.Warning(
"Bulkhead rejected operation. Max parallelization: {MaxParallel}, Queue: {MaxQueue}",
maxParallelization,
maxQueuingActions);
return Task.CompletedTask;
});
}
}
/// <summary>
/// Extension methods for applying retry policies
/// </summary>
public static class RetryPolicyExtensions
{
/// <summary>
/// Executes an operation with retry policy
/// </summary>
public static async Task<T> ExecuteWithRetryAsync<T>(
this IAsyncPolicy<T> policy,
Func<Task<T>> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
return await policy.ExecuteAsync(async ctx => await operation(), context);
}
/// <summary>
/// Executes an operation with retry policy (non-generic)
/// </summary>
public static async Task ExecuteWithRetryAsync(
this IAsyncPolicy policy,
Func<Task> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
await policy.ExecuteAsync(async ctx => await operation(), context);
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages client sessions for the gRPC service.
/// Tracks active sessions with unique session IDs.
/// </summary>
public class SessionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SessionManager>();
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private bool _disposed;
/// <summary>
/// Gets the number of active sessions.
/// </summary>
public int ActiveSessionCount => _sessions.Count;
/// <summary>
/// Creates a new session for a client.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="apiKey">The API key used for authentication (optional).</param>
/// <returns>The session ID for the new session.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public string CreateSession(string clientId, string apiKey = null)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SessionManager));
}
var sessionId = Guid.NewGuid().ToString("N");
var sessionInfo = new SessionInfo
{
SessionId = sessionId,
ClientId = clientId ?? string.Empty,
ApiKey = apiKey ?? string.Empty,
ConnectedAt = DateTime.UtcNow,
LastActivity = DateTime.UtcNow
};
_sessions[sessionId] = sessionInfo;
Logger.Information("Created session {SessionId} for client {ClientId}", sessionId, clientId);
return sessionId;
}
/// <summary>
/// Validates a session ID and updates the last activity timestamp.
/// </summary>
/// <param name="sessionId">The session ID to validate.</param>
/// <returns>True if the session is valid; otherwise, false.</returns>
public bool ValidateSession(string sessionId)
{
if (_disposed)
{
return false;
}
if (string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo))
{
sessionInfo.LastActivity = DateTime.UtcNow;
return true;
}
return false;
}
/// <summary>
/// Gets the session information for a session ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session information, or null if not found.</returns>
public SessionInfo GetSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return null;
}
_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo);
return sessionInfo;
}
/// <summary>
/// Terminates a session.
/// </summary>
/// <param name="sessionId">The session ID to terminate.</param>
/// <returns>True if the session was terminated; otherwise, false.</returns>
public bool TerminateSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryRemove(sessionId, out SessionInfo sessionInfo))
{
Logger.Information("Terminated session {SessionId} for client {ClientId}", sessionId, sessionInfo.ClientId);
return true;
}
return false;
}
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>A list of all active session information.</returns>
public IReadOnlyList<SessionInfo> GetAllSessions()
{
return _sessions.Values.ToList();
}
/// <summary>
/// Disposes the session manager and clears all sessions.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
var count = _sessions.Count;
_sessions.Clear();
Logger.Information("SessionManager disposed, cleared {Count} sessions", count);
}
}
/// <summary>
/// Contains information about a client session.
/// </summary>
public class SessionInfo
{
/// <summary>
/// Gets or sets the unique session identifier.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the API key used for this session.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time when the session was created.
/// </summary>
public DateTime ConnectedAt { get; set; }
/// <summary>
/// Gets or sets the time of the last activity on this session.
/// </summary>
public DateTime LastActivity { get; set; }
/// <summary>
/// Gets the connected time as UTC ticks for the gRPC response.
/// </summary>
public long ConnectedSinceUtcTicks => ConnectedAt.Ticks;
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Service for collecting and formatting status information from various LmxProxy components
/// </summary>
public class StatusReportService
{
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
private readonly HealthCheckService _healthCheckService;
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
/// <summary>
/// Initializes a new instance of the StatusReportService class
/// </summary>
public StatusReportService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics,
HealthCheckService healthCheckService,
DetailedHealthCheckService? detailedHealthCheckService = null)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
_healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService));
_detailedHealthCheckService = detailedHealthCheckService;
}
/// <summary>
/// Generates a comprehensive status report as HTML
/// </summary>
public async Task<string> GenerateHtmlReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return GenerateHtmlFromStatusData(statusData);
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating HTML status report");
return GenerateErrorHtml(ex);
}
}
/// <summary>
/// Generates a comprehensive status report as JSON
/// </summary>
public async Task<string> GenerateJsonReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return JsonSerializer.Serialize(statusData, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating JSON status report");
return JsonSerializer.Serialize(new { error = ex.Message }, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
}
/// <summary>
/// Checks if the service is healthy
/// </summary>
public async Task<bool> IsHealthyAsync()
{
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
return healthResult.Status == HealthStatus.Healthy;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking health status");
return false;
}
}
/// <summary>
/// Collects status data from all components
/// </summary>
private async Task<StatusData> CollectStatusDataAsync()
{
var statusData = new StatusData
{
Timestamp = DateTime.UtcNow,
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"
};
// Collect connection status
statusData.Connection = new ConnectionStatus
{
IsConnected = _scadaClient.IsConnected,
State = _scadaClient.ConnectionState.ToString(),
NodeName = "N/A", // Could be extracted from configuration if needed
GalaxyName = "N/A" // Could be extracted from configuration if needed
};
// Collect subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
statusData.Subscriptions = new SubscriptionStatus
{
TotalClients = subscriptionStats.TotalClients,
TotalTags = subscriptionStats.TotalTags,
ActiveSubscriptions = subscriptionStats.TotalTags // Assuming same for simplicity
};
// Collect performance metrics
Dictionary<string, MetricsStatistics> perfMetrics = _performanceMetrics.GetStatistics();
statusData.Performance = new PerformanceStatus
{
TotalOperations = perfMetrics.Values.Sum(m => m.TotalCount),
AverageSuccessRate = perfMetrics.Count > 0 ? perfMetrics.Values.Average(m => m.SuccessRate) : 1.0,
Operations = perfMetrics.ToDictionary(
kvp => kvp.Key,
kvp => new OperationStatus
{
TotalCount = kvp.Value.TotalCount,
SuccessRate = kvp.Value.SuccessRate,
AverageMilliseconds = kvp.Value.AverageMilliseconds,
MinMilliseconds = kvp.Value.MinMilliseconds,
MaxMilliseconds = kvp.Value.MaxMilliseconds
})
};
// Collect health check results
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.Health = new HealthInfo
{
Status = healthResult.Status.ToString(),
Description = healthResult.Description ?? "",
Data = healthResult.Data?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") ??
new Dictionary<string, string>()
};
// Collect detailed health check if available
if (_detailedHealthCheckService != null)
{
HealthCheckResult detailedHealthResult =
await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.DetailedHealth = new HealthInfo
{
Status = detailedHealthResult.Status.ToString(),
Description = detailedHealthResult.Description ?? "",
Data = detailedHealthResult.Data?.ToDictionary(kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? "") ?? new Dictionary<string, string>()
};
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error collecting health check data");
statusData.Health = new HealthInfo
{
Status = "Error",
Description = $"Health check failed: {ex.Message}",
Data = new Dictionary<string, string>()
};
}
return statusData;
}
/// <summary>
/// Generates HTML from status data
/// </summary>
private static string GenerateHtmlFromStatusData(StatusData statusData)
{
var html = new StringBuilder();
html.AppendLine("<!DOCTYPE html>");
html.AppendLine("<html>");
html.AppendLine("<head>");
html.AppendLine(" <title>LmxProxy Status</title>");
html.AppendLine(" <meta charset=\"utf-8\">");
html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
html.AppendLine(" <style>");
html.AppendLine(
" body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }");
html.AppendLine(
" .container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
html.AppendLine(" .header { text-align: center; margin-bottom: 30px; }");
html.AppendLine(
" .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }");
html.AppendLine(
" .status-card { background: #f9f9f9; padding: 15px; border-radius: 6px; border-left: 4px solid #007acc; }");
html.AppendLine(" .status-card h3 { margin-top: 0; color: #333; }");
html.AppendLine(" .status-value { font-weight: bold; color: #007acc; }");
html.AppendLine(" .status-healthy { color: #28a745; }");
html.AppendLine(" .status-warning { color: #ffc107; }");
html.AppendLine(" .status-error { color: #dc3545; }");
html.AppendLine(" .status-connected { border-left-color: #28a745; }");
html.AppendLine(" .status-disconnected { border-left-color: #dc3545; }");
html.AppendLine(" table { width: 100%; border-collapse: collapse; margin-top: 10px; }");
html.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }");
html.AppendLine(" th { background-color: #f2f2f2; }");
html.AppendLine(
" .timestamp { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }");
html.AppendLine(" </style>");
html.AppendLine("</head>");
html.AppendLine("<body>");
html.AppendLine(" <div class=\"container\">");
// Header
html.AppendLine(" <div class=\"header\">");
html.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
html.AppendLine($" <p>Service: {statusData.ServiceName} | Version: {statusData.Version}</p>");
html.AppendLine(" </div>");
html.AppendLine(" <div class=\"status-grid\">");
// Connection Status Card
string connectionClass = statusData.Connection.IsConnected ? "status-connected" : "status-disconnected";
string connectionStatusText = statusData.Connection.IsConnected ? "Connected" : "Disconnected";
string connectionStatusClass = statusData.Connection.IsConnected ? "status-healthy" : "status-error";
html.AppendLine($" <div class=\"status-card {connectionClass}\">");
html.AppendLine(" <h3>MxAccess Connection</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {connectionStatusClass}\">{connectionStatusText}</span></p>");
html.AppendLine(
$" <p>State: <span class=\"status-value\">{statusData.Connection.State}</span></p>");
html.AppendLine(" </div>");
// Subscription Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Subscriptions</h3>");
html.AppendLine(
$" <p>Total Clients: <span class=\"status-value\">{statusData.Subscriptions.TotalClients}</span></p>");
html.AppendLine(
$" <p>Total Tags: <span class=\"status-value\">{statusData.Subscriptions.TotalTags}</span></p>");
html.AppendLine(
$" <p>Active Subscriptions: <span class=\"status-value\">{statusData.Subscriptions.ActiveSubscriptions}</span></p>");
html.AppendLine(" </div>");
// Performance Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Performance</h3>");
html.AppendLine(
$" <p>Total Operations: <span class=\"status-value\">{statusData.Performance.TotalOperations:N0}</span></p>");
html.AppendLine(
$" <p>Success Rate: <span class=\"status-value\">{statusData.Performance.AverageSuccessRate:P2}</span></p>");
html.AppendLine(" </div>");
// Health Status Card
string healthStatusClass = statusData.Health.Status.ToLowerInvariant() switch
{
"healthy" => "status-healthy",
"degraded" => "status-warning",
_ => "status-error"
};
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Health Status</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {healthStatusClass}\">{statusData.Health.Status}</span></p>");
html.AppendLine(
$" <p>Description: <span class=\"status-value\">{statusData.Health.Description}</span></p>");
html.AppendLine(" </div>");
html.AppendLine(" </div>");
// Performance Metrics Table
if (statusData.Performance.Operations.Any())
{
html.AppendLine(" <div class=\"status-card\" style=\"margin-top: 20px;\">");
html.AppendLine(" <h3>Operation Performance Metrics</h3>");
html.AppendLine(" <table>");
html.AppendLine(" <tr>");
html.AppendLine(" <th>Operation</th>");
html.AppendLine(" <th>Count</th>");
html.AppendLine(" <th>Success Rate</th>");
html.AppendLine(" <th>Avg (ms)</th>");
html.AppendLine(" <th>Min (ms)</th>");
html.AppendLine(" <th>Max (ms)</th>");
html.AppendLine(" </tr>");
foreach (KeyValuePair<string, OperationStatus> operation in statusData.Performance.Operations)
{
html.AppendLine(" <tr>");
html.AppendLine($" <td>{operation.Key}</td>");
html.AppendLine($" <td>{operation.Value.TotalCount:N0}</td>");
html.AppendLine($" <td>{operation.Value.SuccessRate:P2}</td>");
html.AppendLine($" <td>{operation.Value.AverageMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MinMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MaxMilliseconds:F2}</td>");
html.AppendLine(" </tr>");
}
html.AppendLine(" </table>");
html.AppendLine(" </div>");
}
// Timestamp
html.AppendLine(
$" <div class=\"timestamp\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div>");
html.AppendLine(" </div>");
html.AppendLine("</body>");
html.AppendLine("</html>");
return html.ToString();
}
/// <summary>
/// Generates error HTML when status collection fails
/// </summary>
private static string GenerateErrorHtml(Exception ex)
{
return $@"<!DOCTYPE html>
<html>
<head>
<title>LmxProxy Status - Error</title>
<meta charset=""utf-8"">
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.error {{ color: #dc3545; background-color: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; }}
</style>
</head>
<body>
<div class=""container"">
<h1>LmxProxy Status Dashboard</h1>
<div class=""error"">
<h3>Error Loading Status</h3>
<p>An error occurred while collecting status information:</p>
<p><strong>{ex.Message}</strong></p>
</div>
<div style=""text-align: center; margin-top: 20px; color: #666; font-size: 0.9em;"">
Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
</div>
</div>
</body>
</html>";
}
}
/// <summary>
/// Data structure for holding complete status information
/// </summary>
public class StatusData
{
public DateTime Timestamp { get; set; }
public string ServiceName { get; set; } = "";
public string Version { get; set; } = "";
public ConnectionStatus Connection { get; set; } = new();
public SubscriptionStatus Subscriptions { get; set; } = new();
public PerformanceStatus Performance { get; set; } = new();
public HealthInfo Health { get; set; } = new();
public HealthInfo? DetailedHealth { get; set; }
}
/// <summary>
/// Connection status information
/// </summary>
public class ConnectionStatus
{
public bool IsConnected { get; set; }
public string State { get; set; } = "";
public string NodeName { get; set; } = "";
public string GalaxyName { get; set; } = "";
}
/// <summary>
/// Subscription status information
/// </summary>
public class SubscriptionStatus
{
public int TotalClients { get; set; }
public int TotalTags { get; set; }
public int ActiveSubscriptions { get; set; }
}
/// <summary>
/// Performance status information
/// </summary>
public class PerformanceStatus
{
public long TotalOperations { get; set; }
public double AverageSuccessRate { get; set; }
public Dictionary<string, OperationStatus> Operations { get; set; } = new();
}
/// <summary>
/// Individual operation status
/// </summary>
public class OperationStatus
{
public long TotalCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
}
/// <summary>
/// Health check status information
/// </summary>
public class HealthInfo
{
public string Status { get; set; } = "";
public string Description { get; set; } = "";
public Dictionary<string, string> Data { get; set; } = new();
}
}

View File

@@ -0,0 +1,315 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// HTTP web server that serves status information for the LmxProxy service
/// </summary>
public class StatusWebServer : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
private readonly WebServerConfiguration _configuration;
private readonly StatusReportService _statusReportService;
private CancellationTokenSource? _cancellationTokenSource;
private bool _disposed;
private HttpListener? _httpListener;
private Task? _listenerTask;
/// <summary>
/// Initializes a new instance of the StatusWebServer class
/// </summary>
/// <param name="configuration">Web server configuration</param>
/// <param name="statusReportService">Service for collecting status information</param>
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService));
}
/// <summary>
/// Disposes the web server and releases resources
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Stop();
_cancellationTokenSource?.Dispose();
_httpListener?.Close();
}
/// <summary>
/// Starts the HTTP web server
/// </summary>
/// <returns>True if started successfully, false otherwise</returns>
public bool Start()
{
try
{
if (!_configuration.Enabled)
{
Logger.Information("Status web server is disabled");
return true;
}
Logger.Information("Starting status web server on port {Port}", _configuration.Port);
_httpListener = new HttpListener();
// Configure the URL prefix
string prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/";
if (!prefix.EndsWith("/"))
{
prefix += "/";
}
_httpListener.Prefixes.Add(prefix);
_httpListener.Start();
_cancellationTokenSource = new CancellationTokenSource();
_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token));
Logger.Information("Status web server started successfully on {Prefix}", prefix);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to start status web server");
return false;
}
}
/// <summary>
/// Stops the HTTP web server
/// </summary>
/// <returns>True if stopped successfully, false otherwise</returns>
public bool Stop()
{
try
{
if (!_configuration.Enabled || _httpListener == null)
{
return true;
}
Logger.Information("Stopping status web server");
_cancellationTokenSource?.Cancel();
if (_listenerTask != null)
{
try
{
_listenerTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Logger.Warning(ex, "Error waiting for listener task to complete");
}
}
_httpListener?.Stop();
_httpListener?.Close();
Logger.Information("Status web server stopped successfully");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping status web server");
return false;
}
}
/// <summary>
/// Main request handling loop
/// </summary>
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
{
Logger.Information("Status web server listener started");
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
{
try
{
HttpListenerContext? context = await _httpListener.GetContextAsync();
// Handle request asynchronously without waiting
_ = Task.Run(async () =>
{
try
{
await HandleRequestAsync(context);
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request from {RemoteEndPoint}",
context.Request.RemoteEndPoint);
}
}, cancellationToken);
}
catch (ObjectDisposedException)
{
// Expected when stopping the listener
break;
}
catch (HttpListenerException ex) when (ex.ErrorCode == 995) // ERROR_OPERATION_ABORTED
{
// Expected when stopping the listener
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error in request listener loop");
// Brief delay before continuing to avoid tight error loops
try
{
await Task.Delay(1000, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
Logger.Information("Status web server listener stopped");
}
/// <summary>
/// Handles a single HTTP request
/// </summary>
private async Task HandleRequestAsync(HttpListenerContext context)
{
HttpListenerRequest? request = context.Request;
HttpListenerResponse response = context.Response;
try
{
Logger.Debug("Handling {Method} request to {Url} from {RemoteEndPoint}",
request.HttpMethod, request.Url?.AbsolutePath, request.RemoteEndPoint);
// Only allow GET requests
if (request.HttpMethod != "GET")
{
response.StatusCode = 405; // Method Not Allowed
response.StatusDescription = "Method Not Allowed";
await WriteResponseAsync(response, "Only GET requests are supported", "text/plain");
return;
}
string path = request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
switch (path)
{
case "/":
await HandleStatusPageAsync(response);
break;
case "/api/status":
await HandleStatusApiAsync(response);
break;
case "/api/health":
await HandleHealthApiAsync(response);
break;
default:
response.StatusCode = 404; // Not Found
response.StatusDescription = "Not Found";
await WriteResponseAsync(response, "Resource not found", "text/plain");
break;
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request");
try
{
response.StatusCode = 500; // Internal Server Error
response.StatusDescription = "Internal Server Error";
await WriteResponseAsync(response, "Internal server error", "text/plain");
}
catch (Exception responseEx)
{
Logger.Error(responseEx, "Error writing error response");
}
}
finally
{
try
{
response.Close();
}
catch (Exception ex)
{
Logger.Warning(ex, "Error closing HTTP response");
}
}
}
/// <summary>
/// Handles the main status page (HTML)
/// </summary>
private async Task HandleStatusPageAsync(HttpListenerResponse response)
{
string statusHtml = await _statusReportService.GenerateHtmlReportAsync();
await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8");
}
/// <summary>
/// Handles the status API endpoint (JSON)
/// </summary>
private async Task HandleStatusApiAsync(HttpListenerResponse response)
{
string statusJson = await _statusReportService.GenerateJsonReportAsync();
await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8");
}
/// <summary>
/// Handles the health API endpoint (simple text)
/// </summary>
private async Task HandleHealthApiAsync(HttpListenerResponse response)
{
bool isHealthy = await _statusReportService.IsHealthyAsync();
string healthText = isHealthy ? "OK" : "UNHEALTHY";
response.StatusCode = isHealthy ? 200 : 503; // Service Unavailable if unhealthy
await WriteResponseAsync(response, healthText, "text/plain");
}
/// <summary>
/// Writes a response to the HTTP context
/// </summary>
private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType)
{
response.ContentType = contentType;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
byte[] buffer = Encoding.UTF8.GetBytes(content);
response.ContentLength64 = buffer.Length;
using (Stream? output = response.OutputStream)
{
await output.WriteAsync(buffer, 0, buffer.Length);
}
}
}
}

View File

@@ -0,0 +1,535 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client
/// statistics.
/// </summary>
public class SubscriptionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SubscriptionManager>();
// Configuration for channel buffering
private readonly int _channelCapacity;
private readonly BoundedChannelFullMode _channelFullMode;
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions = new();
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
private readonly IScadaClient _scadaClient;
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionManager" /> class.
/// </summary>
/// <param name="scadaClient">The SCADA client to use for subscriptions.</param>
/// <param name="configuration">The subscription configuration.</param>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="scadaClient" /> or <paramref name="configuration" />
/// is null.
/// </exception>
public SubscriptionManager(IScadaClient scadaClient, SubscriptionConfiguration configuration)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
SubscriptionConfiguration configuration1 =
configuration ?? throw new ArgumentNullException(nameof(configuration));
_channelCapacity = configuration1.ChannelCapacity;
_channelFullMode = ParseChannelFullMode(configuration1.ChannelFullMode);
// Subscribe to connection state changes
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
Logger.Information("SubscriptionManager initialized with channel capacity: {Capacity}, full mode: {Mode}",
_channelCapacity, _channelFullMode);
}
/// <summary>
/// Disposes the <see cref="SubscriptionManager" />, unsubscribing all clients and cleaning up resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Logger.Information("Disposing SubscriptionManager");
// Unsubscribe from connection state changes
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
// Unsubscribe all clients
var clientIds = _clientSubscriptions.Keys.ToList();
foreach (string? clientId in clientIds)
{
UnsubscribeClient(clientId);
}
_clientSubscriptions.Clear();
_tagSubscriptions.Clear();
// Dispose the lock
_lock?.Dispose();
}
/// <summary>
/// Gets the number of active client subscriptions.
/// </summary>
public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count;
/// <summary>
/// Parses the channel full mode string to <see cref="BoundedChannelFullMode" />.
/// </summary>
/// <param name="mode">The mode string.</param>
/// <returns>The parsed <see cref="BoundedChannelFullMode" /> value.</returns>
private static BoundedChannelFullMode ParseChannelFullMode(string mode)
{
return mode?.ToUpperInvariant() switch
{
"DROPOLDEST" => BoundedChannelFullMode.DropOldest,
"DROPNEWEST" => BoundedChannelFullMode.DropNewest,
"WAIT" => BoundedChannelFullMode.Wait,
_ => BoundedChannelFullMode.DropOldest // Default
};
}
/// <summary>
/// Creates a new subscription for a client to a set of tag addresses.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="ct">Optional cancellation token.</param>
/// <returns>A channel for receiving tag updates.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public async Task<Channel<(string address, Vtq vtq)>> SubscribeAsync(
string clientId,
IEnumerable<string> addresses,
CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SubscriptionManager));
}
var addressList = addresses.ToList();
Logger.Information("Client {ClientId} subscribing to {Count} tags", clientId, addressList.Count);
// Create a bounded channel for this client with buffering
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(new BoundedChannelOptions(_channelCapacity)
{
FullMode = _channelFullMode,
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
Logger.Debug("Created bounded channel for client {ClientId} with capacity {Capacity}", clientId,
_channelCapacity);
var clientSubscription = new ClientSubscription
{
ClientId = clientId,
Channel = channel,
Addresses = new HashSet<string>(addressList),
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct)
};
_clientSubscriptions[clientId] = clientSubscription;
// Subscribe to each tag
foreach (string? address in addressList)
{
await SubscribeToTagAsync(address, clientId);
}
// Handle client disconnection
clientSubscription.CancellationTokenSource.Token.Register(() =>
{
Logger.Information("Client {ClientId} disconnected, cleaning up subscriptions", clientId);
UnsubscribeClient(clientId);
});
return channel;
}
/// <summary>
/// Unsubscribes a client from all tags and cleans up resources.
/// </summary>
/// <param name="clientId">The client identifier.</param>
public void UnsubscribeClient(string clientId)
{
if (_clientSubscriptions.TryRemove(clientId, out ClientSubscription? clientSubscription))
{
Logger.Information(
"Unsubscribing client {ClientId} from {Count} tags. Stats: Delivered={Delivered}, Dropped={Dropped}",
clientId, clientSubscription.Addresses.Count,
clientSubscription.DeliveredMessageCount, clientSubscription.DroppedMessageCount);
_lock.EnterWriteLock();
try
{
foreach (string? address in clientSubscription.Addresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
tagSubscription.ClientIds.Remove(clientId);
// If no more clients are subscribed to this tag, unsubscribe from SCADA
if (tagSubscription.ClientIds.Count == 0)
{
Logger.Information(
"No more clients subscribed to {Address}, removing SCADA subscription", address);
_tagSubscriptions.TryRemove(address, out _);
// Dispose the SCADA subscription
Task.Run(async () =>
{
try
{
if (tagSubscription.ScadaSubscription != null)
{
await tagSubscription.ScadaSubscription.DisposeAsync();
Logger.Debug("Successfully disposed SCADA subscription for {Address}",
address);
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error disposing SCADA subscription for {Address}", address);
}
});
}
else
{
Logger.Debug(
"Client {ClientId} removed from {Address} subscription (remaining clients: {Count})",
clientId, address, tagSubscription.ClientIds.Count);
}
}
}
}
finally
{
_lock.ExitWriteLock();
}
// Complete the channel
clientSubscription.Channel.Writer.TryComplete();
clientSubscription.CancellationTokenSource.Dispose();
}
}
/// <summary>
/// Subscribes a client to a tag address, creating a new SCADA subscription if needed.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="clientId">The client identifier.</param>
private async Task SubscribeToTagAsync(string address, string clientId)
{
bool needsSubscription;
TagSubscription? tagSubscription;
_lock.EnterWriteLock();
try
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? existingSubscription))
{
// Tag is already subscribed, just add this client
existingSubscription.ClientIds.Add(clientId);
Logger.Debug(
"Client {ClientId} added to existing subscription for {Address} (total clients: {Count})",
clientId, address, existingSubscription.ClientIds.Count);
return;
}
// Create new tag subscription and reserve the spot
tagSubscription = new TagSubscription
{
Address = address,
ClientIds = new HashSet<string> { clientId }
};
_tagSubscriptions[address] = tagSubscription;
needsSubscription = true;
}
finally
{
_lock.ExitWriteLock();
}
if (needsSubscription && tagSubscription != null)
{
// Subscribe to SCADA outside of lock to avoid blocking
Logger.Debug("Creating new SCADA subscription for {Address}", address);
try
{
IAsyncDisposable scadaSubscription = await _scadaClient.SubscribeAsync(
new[] { address },
(addr, vtq) => OnTagValueChanged(addr, vtq),
CancellationToken.None);
_lock.EnterWriteLock();
try
{
tagSubscription.ScadaSubscription = scadaSubscription;
}
finally
{
_lock.ExitWriteLock();
}
Logger.Information("Successfully subscribed to {Address} for client {ClientId}", address, clientId);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to subscribe to {Address}", address);
// Remove the failed subscription
_lock.EnterWriteLock();
try
{
_tagSubscriptions.TryRemove(address, out _);
}
finally
{
_lock.ExitWriteLock();
}
throw;
}
}
}
/// <summary>
/// Handles tag value changes and delivers updates to all subscribed clients.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="vtq">The value, timestamp, and quality.</param>
private void OnTagValueChanged(string address, Vtq vtq)
{
Logger.Debug("Tag value changed: {Address} = {Vtq}", address, vtq);
_lock.EnterReadLock();
try
{
if (!_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
Logger.Warning("Received update for untracked tag {Address}", address);
return;
}
// Send update to all subscribed clients
// Use the existing collection directly without ToList() since we're in a read lock
foreach (string? clientId in tagSubscription.ClientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
if (!clientSubscription.Channel.Writer.TryWrite((address, vtq)))
{
// Channel is full - with DropOldest mode, this should rarely happen
Logger.Warning(
"Channel full for client {ClientId}, dropping message for {Address}. Consider increasing buffer size.",
clientId, address);
clientSubscription.DroppedMessageCount++;
}
else
{
clientSubscription.DeliveredMessageCount++;
}
}
catch (InvalidOperationException ex) when (ex.Message.Contains("closed"))
{
Logger.Debug("Channel closed for client {ClientId}, removing subscription", clientId);
// Schedule cleanup of disconnected client
Task.Run(() => UnsubscribeClient(clientId));
}
catch (Exception ex)
{
Logger.Error(ex, "Error sending update to client {ClientId}", clientId);
}
}
}
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Gets current subscription statistics for all clients and tags.
/// </summary>
/// <returns>A <see cref="SubscriptionStats" /> object containing statistics.</returns>
public virtual SubscriptionStats GetSubscriptionStats()
{
_lock.EnterReadLock();
try
{
var tagClientCounts = _tagSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ClientIds.Count);
var clientStats = _clientSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => new ClientStats
{
SubscribedTags = kvp.Value.Addresses.Count,
DeliveredMessages = kvp.Value.DeliveredMessageCount,
DroppedMessages = kvp.Value.DroppedMessageCount
});
return new SubscriptionStats
{
TotalClients = _clientSubscriptions.Count,
TotalTags = _tagSubscriptions.Count,
TagClientCounts = tagClientCounts,
ClientStats = clientStats
};
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Handles SCADA client connection state changes and notifies clients of disconnection.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The connection state change event arguments.</param>
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Logger.Information("Connection state changed from {Previous} to {Current}",
e.PreviousState, e.CurrentState);
// If we're disconnected, notify all subscribed clients with bad quality
if (e.CurrentState != ConnectionState.Connected)
{
Task.Run(async () =>
{
try
{
await NotifyAllClientsOfDisconnection();
}
catch (Exception ex)
{
Logger.Error(ex, "Error notifying clients of disconnection");
}
});
}
}
/// <summary>
/// Notifies all clients of a SCADA disconnection by sending bad quality updates.
/// </summary>
private async Task NotifyAllClientsOfDisconnection()
{
Logger.Information("Notifying all clients of disconnection");
var badQualityVtq = new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Get all unique addresses being subscribed to
var allAddresses = _tagSubscriptions.Keys.ToList();
// Send bad quality update for each address to all subscribed clients
foreach (string? address in allAddresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
var clientIds = tagSubscription.ClientIds.ToList();
foreach (string? clientId in clientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
await clientSubscription.Channel.Writer.WriteAsync((address, badQualityVtq));
Logger.Debug("Sent bad quality notification for {Address} to client {ClientId}",
address, clientId);
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to send bad quality notification to client {ClientId}",
clientId);
}
}
}
}
}
}
/// <summary>
/// Represents a client's subscription, including channel, addresses, and statistics.
/// </summary>
private class ClientSubscription
{
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the channel for delivering tag updates.
/// </summary>
public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!;
/// <summary>
/// Gets or sets the set of addresses the client is subscribed to.
/// </summary>
public HashSet<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets the cancellation token source for the client.
/// </summary>
public CancellationTokenSource CancellationTokenSource { get; set; } = null!;
/// <summary>
/// Gets or sets the count of delivered messages.
/// </summary>
public long DeliveredMessageCount { get; set; }
/// <summary>
/// Gets or sets the count of dropped messages.
/// </summary>
public long DroppedMessageCount { get; set; }
}
/// <summary>
/// Represents a tag subscription, including address, client IDs, and SCADA subscription handle.
/// </summary>
private class TagSubscription
{
/// <summary>
/// Gets or sets the tag address.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the set of client IDs subscribed to this tag.
/// </summary>
public HashSet<string> ClientIds { get; set; } = new();
/// <summary>
/// Gets or sets the SCADA subscription handle.
/// </summary>
public IAsyncDisposable ScadaSubscription { get; set; } = null!;
}
}
}

View File

@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>Exe</OutputType>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
<!-- Force x86 architecture for all configurations (required by ArchestrA.MXAccess) -->
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core" Version="2.46.6"/>
<PackageReference Include="Grpc.Tools" Version="2.51.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.21.12"/>
<PackageReference Include="Topshelf" Version="4.3.0"/>
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
<PackageReference Include="Serilog" Version="2.10.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0"/>
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0"/>
<PackageReference Include="System.Threading.Channels" Version="4.7.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32"/>
<PackageReference Include="Polly" Version="7.2.4"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1"/>
</ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MXAccess">
<HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both"/>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="App.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Grpc": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
}
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.json",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
"WithProcessId",
"WithEnvironmentName"
],
"Properties": {
"Application": "LmxProxy",
"Environment": "Production"
}
}
}

View File

@@ -0,0 +1,84 @@
{
"GrpcPort": 50051,
"ApiKeyConfigFile": "apikeys.json",
"Subscription": {
"ChannelCapacity": 1000,
"ChannelFullMode": "DropOldest"
},
"ServiceRecovery": {
"FirstFailureDelayMinutes": 1,
"SecondFailureDelayMinutes": 5,
"SubsequentFailureDelayMinutes": 10,
"ResetPeriodDays": 1
},
"Connection": {
"MonitorIntervalSeconds": 5,
"ConnectionTimeoutSeconds": 30,
"AutoReconnect": true,
"ReadTimeoutSeconds": 5,
"WriteTimeoutSeconds": 5,
"MaxConcurrentOperations": 10
},
"PerformanceMetrics": {
"ReportingIntervalSeconds": 60,
"Enabled": true,
"MaxSamplesPerMetric": 1000
},
"HealthCheck": {
"Enabled": true,
"TestTagAddress": "TestChannel.TestDevice.TestTag",
"MaxStaleDataMinutes": 5
},
"RetryPolicies": {
"ReadRetryCount": 3,
"WriteRetryCount": 3,
"ConnectionRetryCount": 5,
"CircuitBreakerThreshold": 5,
"CircuitBreakerDurationSeconds": 30
},
"Tls": {
"Enabled": true,
"ServerCertificatePath": "certs/server.crt",
"ServerKeyPath": "certs/server.key",
"ClientCaCertificatePath": "certs/ca.crt",
"RequireClientCertificate": false,
"CheckCertificateRevocation": false
},
"WebServer": {
"Enabled": true,
"Port": 8080
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Grpc": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId"
]
}
}

View File

@@ -0,0 +1,52 @@
{
"GrpcPort": 50051,
"ApiKeyConfigFile": "apikeys.json",
"Connection": {
"MonitorIntervalSeconds": 5,
"ConnectionTimeoutSeconds": 30,
"AutoReconnect": true,
"ReadTimeoutSeconds": 5,
"WriteTimeoutSeconds": 5,
"MaxConcurrentOperations": 10
},
"Subscription": {
"ChannelCapacity": 10000,
"ChannelFullMode": "DropOldest"
},
"ServiceRecovery": {
"FirstFailureDelayMinutes": 1,
"SecondFailureDelayMinutes": 5,
"SubsequentFailureDelayMinutes": 10,
"ResetPeriodDays": 1
},
"Tls": {
"Enabled": true,
"ServerCertificatePath": "certs/server.crt",
"ServerKeyPath": "certs/server.key",
"ClientCaCertificatePath": "certs/ca.crt",
"RequireClientCertificate": false,
"CheckCertificateRevocation": false
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7
}
}
]
}
}

228
windev.md Normal file
View File

@@ -0,0 +1,228 @@
# WinDev — Windows Development VM
Remote Windows 10 VM used for development and testing.
- **ESXi host**: See [esxi.md](/Users/dohertj2/Desktop/netfix/esxi.md) — VM name `WW_DEV_VM` on ESXi 8.0.3 at 10.2.0.12
- **Backup**: See [veeam.md](/Users/dohertj2/Desktop/netfix/veeam.md) — Veeam B&R 12.3 at 10.100.0.30. Dedicated job "Backup WW_DEV_VM" targeting NAS repo. First restore point (2026-03-21) = **Baseline**: Win10 + .NET 10 SDK + .NET Fx 4.8 + Git + 7-Zip + Chrome + Claude Code + csharp-ls.
## Connection Details
| Field | Value |
|-------|-------|
| **Hostname** | DESKTOP-6JL3KKO |
| **IP** | 10.100.0.48 |
| **OS** | Windows 10 Enterprise (10.0.19045), 64-bit |
| **CPU** | Intel Xeon E5-2697 v4 @ 2.30GHz |
| **RAM** | ~12 GB |
| **Disk** | C: 235 GB free / 256 GB total |
| **User** | `dohertj2` (local administrator) |
| **SSH** | OpenSSH Server (passwordless via ed25519 key) |
| **Default shell** | cmd.exe |
## SSH Access
Passwordless SSH is configured. An alias `windev` is set up in `~/.ssh/config`.
```bash
# Connect
ssh windev
# Run a command
ssh windev "hostname"
# Run PowerShell
ssh windev "powershell -Command \"Get-Process\""
```
### SSH Config Entry (`~/.ssh/config`)
```
Host windev
HostName 10.100.0.48
User dohertj2
IdentityFile ~/.ssh/id_ed25519
```
### How Passwordless Auth Works
Since `dohertj2` is in the local Administrators group, Windows OpenSSH uses a special authorized keys file instead of the per-user `~/.ssh/authorized_keys`:
```
C:\ProgramData\ssh\administrators_authorized_keys
```
This is configured in `C:\ProgramData\ssh\sshd_config` via the `Match Group administrators` block. If you need to add another key, append it to that file and ensure ACLs are correct:
```powershell
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
```
## File Transfer
```bash
# Copy file to Windows
scp localfile.txt windev:C:/Users/dohertj2/Desktop/
# Copy file from Windows
scp windev:C:/Users/dohertj2/Desktop/file.txt ./
# Copy directory recursively
scp -r ./mydir windev:C:/Users/dohertj2/Desktop/mydir
```
## Running Commands
The default shell is `cmd.exe`. For PowerShell, prefix commands explicitly.
```bash
# cmd (default)
ssh windev "dir C:\Users\dohertj2"
# PowerShell
ssh windev "powershell -Command \"Get-Service | Where-Object { \$_.Status -eq 'Running' }\""
# Multi-line PowerShell script
ssh windev "powershell -File C:\scripts\myscript.ps1"
```
### Service Management
```bash
# List services
ssh windev "sc query state= all"
# Start/stop a service
ssh windev "sc stop ServiceName"
ssh windev "sc start ServiceName"
# Check a specific service
ssh windev "sc query ServiceName"
```
### Process Management
```bash
# List processes
ssh windev "tasklist"
# Kill a process
ssh windev "taskkill /F /PID 1234"
ssh windev "taskkill /F /IM process.exe"
```
## Installed Software
### Package Manager
| Tool | Version | Install Path |
|------|---------|-------------|
| **winget** | v1.28.190 | AppX package |
The `msstore` source has been removed (requires interactive agreement acceptance). Only the `winget` community source is configured. To install packages:
```bash
ssh windev "winget install --id <PackageId> --silent --disable-interactivity"
```
### Development Tools
| Tool | Version | Install Path |
|------|---------|-------------|
| **7-Zip** | 26.00 (x64) | `C:\Program Files\7-Zip\` |
| **.NET Framework** | 4.8.1 (Developer Pack) | GAC / Reference Assemblies |
| **.NET SDK** | 10.0.201 | `C:\Program Files\dotnet\` |
| **.NET Runtime** | 10.0.5 (Core + ASP.NET + Desktop) | `C:\Program Files\dotnet\` |
| **Git** | 2.53.0.2 | `C:\Program Files\Git\` |
| **Claude Code** | 2.1.81 | `C:\Users\dohertj2\.local\bin\claude.exe` |
Launch with `cc` alias (cmd or Git Bash) which runs `claude --dangerously-skip-permissions --chrome`.
**C# LSP**`csharp-ls` v0.22.0 installed as dotnet global tool (`C:\Users\dohertj2\.dotnet\tools\csharp-ls.exe`). Configured via the `csharp-lsp@claude-plugins-official` plugin. Provides `goToDefinition`, `findReferences`, `hover`, `documentSymbol`, `workspaceSymbol`, `goToImplementation`, and call hierarchy operations on `.cs` files. First invocation in a session is slow (~1-2 min) while the solution loads.
Git is configured with `credential.helper=store` (not GCM — the bundled Git Credential Manager was removed from system config to avoid OAuth/tty issues over SSH). Credentials are stored in `C:\Users\dohertj2\.git-credentials`.
**Gitea** (`gitea.dohertylan.com`) is pre-authenticated — no login prompts. Clone repos with:
```bash
ssh windev "git clone https://gitea.dohertylan.com/dohertj2/<repo>.git C:\src\<repo>"
```
### Applications
| App | Version | Default For |
|-----|---------|-------------|
| **Google Chrome** | 146.0.7680.154 | HTTP, HTTPS, .htm, .html, .pdf |
| **Notepad++** | 8.9.2 | — |
Defaults set via Group Policy `DefaultAssociationsConfiguration` pointing to `C:\Windows\System32\DefaultAssociations.xml`.
### Not Installed
- **Git** — `winget install Git.Git`
- **Python** — `winget install Python.Python.3.12`
- **Visual Studio** — `winget install Microsoft.VisualStudio.2022.BuildTools`
## Network
Single network interface:
| Interface | IP |
|-----------|-----|
| Ethernet0 | 10.100.0.48 (static) |
## Other Users with SSH Access
The `sshus` user also has passwordless SSH access (used for LmxProxy operations). See `lmxproxy_protocol.md` for details on the LmxProxy service running on this machine.
## Backup (Veeam)
Veeam job "Backup WW_DEV_VM" on the Veeam server (10.100.0.30). Targets the NAS repo (`nfs41://10.50.0.25:/mnt/mypool/veeam`).
```bash
# Incremental backup (changed blocks only)
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\""
# Full backup
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM') -FullBackup\""
# Check status
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\""
# List restore points
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\""
```
### Restore Points
| ID | Date | Type | Notes |
|----|------|------|-------|
| `f2cd44a9` | 2026-03-21 14:28 | Full | **Baseline** — Win10 + .NET 10 SDK + .NET Fx 4.8 + Git + 7-Zip + Chrome + Claude Code + csharp-ls (old UUID) |
| `2879a744` | 2026-03-21 15:15 | Increment | UUID fixed to `1BFC4D56-8DFA-A897-D1E4-BF1FD7F0096C`, static IP 10.100.0.48 |
| `b4e87cfe` | 2026-03-21 16:43 | Increment | **Pre-licensing** — Notepad++ added, firewall/Defender disabled, licensing backups staged |
| `f38a8aed` | 2026-03-21 17:01 | Increment | **Post-licensing** — WPS2020 licensing applied and verified working |
## Troubleshooting
### "Permission denied" on SSH key auth
Windows OpenSSH is strict about file permissions on `administrators_authorized_keys`. Re-run:
```powershell
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
```
### Host key changed error
If the VM is rebuilt, clear the old key:
```bash
ssh-keygen -R 10.100.0.48
```
### Firewall blocking SSH
If the VM becomes unreachable, RDP in and check Windows Firewall or disable it:
```powershell
Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False
```