diff --git a/lmxproxy/ZB.MOM.WW.LmxProxy.slnx b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx new file mode 100644 index 0000000..427e0a3 --- /dev/null +++ b/lmxproxy/ZB.MOM.WW.LmxProxy.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/lmxproxy/docs/lmxproxy_protocol.md b/lmxproxy/docs/lmxproxy_protocol.md new file mode 100644 index 0000000..7f645ed --- /dev/null +++ b/lmxproxy/docs/lmxproxy_protocol.md @@ -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` | diff --git a/lmxproxy/docs/lmxproxy_updates.md b/lmxproxy/docs/lmxproxy_updates.md new file mode 100644 index 0000000..f199186 --- /dev/null +++ b/lmxproxy/docs/lmxproxy_updates.md @@ -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.* diff --git a/lmxproxy/lib/ArchestrA.MXAccess.dll b/lmxproxy/lib/ArchestrA.MXAccess.dll new file mode 100755 index 0000000..01414a3 Binary files /dev/null and b/lmxproxy/lib/ArchestrA.MXAccess.dll differ diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs new file mode 100644 index 0000000..d72fa71 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs @@ -0,0 +1,48 @@ +namespace ZB.MOM.WW.LmxProxy.Client; + +/// +/// TLS configuration for LmxProxy client connections +/// +public class ClientTlsConfiguration +{ + /// + /// Gets or sets whether to use TLS for the connection + /// + public bool UseTls { get; set; } = false; + + /// + /// Gets or sets the path to the client certificate file (optional for mutual TLS) + /// + public string? ClientCertificatePath { get; set; } + + /// + /// Gets or sets the path to the client private key file (optional for mutual TLS) + /// + public string? ClientKeyPath { get; set; } + + /// + /// Gets or sets the path to the CA certificate for server validation (optional) + /// + public string? ServerCaCertificatePath { get; set; } + + /// + /// Gets or sets the server name override for certificate validation (optional) + /// + public string? ServerNameOverride { get; set; } + + /// + /// Gets or sets whether to validate the server certificate + /// + public bool ValidateServerCertificate { get; set; } = true; + + /// + /// Gets or sets whether to allow self-signed certificates (for testing only) + /// + public bool AllowSelfSignedCertificates { get; set; } = false; + + /// + /// 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 + /// + public bool IgnoreAllCertificateErrors { get; set; } = false; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs new file mode 100644 index 0000000..618b417 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs @@ -0,0 +1,49 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// Represents the connection state of an LmxProxy client. +/// +public enum ConnectionState +{ + /// Not connected to the server. + Disconnected, + + /// Connection attempt in progress. + Connecting, + + /// Connected and ready for operations. + Connected, + + /// Graceful disconnect in progress. + Disconnecting, + + /// Connection failed with an error. + Error, + + /// Attempting to re-establish a lost connection. + Reconnecting +} + +/// +/// Event arguments for connection state change notifications. +/// +public class ConnectionStateChangedEventArgs : EventArgs +{ + /// The previous connection state. + public ConnectionState OldState { get; } + + /// The new connection state. + public ConnectionState NewState { get; } + + /// Optional message describing the state change (e.g., error details). + public string? Message { get; } + + public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null) + { + OldState = oldState; + NewState = newState; + Message = message; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs new file mode 100644 index 0000000..1da3084 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs @@ -0,0 +1,118 @@ +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// OPC-style quality codes for SCADA data values. +/// Based on OPC DA quality encoding as a single byte: +/// bits 7–6 = major (00=Bad, 01=Uncertain, 11=Good), +/// bits 5–2 = substatus, bits 1–0 = limit (00=None, 01=Low, 10=High, 11=Constant). +/// +public enum Quality : byte +{ + /// Bad – non-specific. + Bad = 0, + + /// Bad – configuration error in the server. + Bad_ConfigError = 4, + + /// Bad – input source is not connected. + Bad_NotConnected = 8, + + /// Bad – device failure detected. + Bad_DeviceFailure = 12, + + /// Bad – sensor failure detected. + Bad_SensorFailure = 16, + + /// Bad – last known value (communication lost, value stale). + Bad_LastKnownValue = 20, + + /// Bad – communication failure. + Bad_CommFailure = 24, + + /// Bad – item is out of service. + Bad_OutOfService = 28, + + /// Uncertain – non-specific. + Uncertain = 64, + + /// Uncertain – non-specific, low limited. + Uncertain_LowLimited = 65, + + /// Uncertain – non-specific, high limited. + Uncertain_HighLimited = 66, + + /// Uncertain – non-specific, constant. + Uncertain_Constant = 67, + + /// Uncertain – last usable value. + Uncertain_LastUsable = 68, + + /// Uncertain – last usable value, low limited. + Uncertain_LastUsable_LL = 69, + + /// Uncertain – last usable value, high limited. + Uncertain_LastUsable_HL = 70, + + /// Uncertain – last usable value, constant. + Uncertain_LastUsable_Cnst = 71, + + /// Uncertain – sensor not accurate. + Uncertain_SensorNotAcc = 80, + + /// Uncertain – sensor not accurate, low limited. + Uncertain_SensorNotAcc_LL = 81, + + /// Uncertain – sensor not accurate, high limited. + Uncertain_SensorNotAcc_HL = 82, + + /// Uncertain – sensor not accurate, constant. + Uncertain_SensorNotAcc_C = 83, + + /// Uncertain – engineering units exceeded. + Uncertain_EuExceeded = 84, + + /// Uncertain – engineering units exceeded, low limited. + Uncertain_EuExceeded_LL = 85, + + /// Uncertain – engineering units exceeded, high limited. + Uncertain_EuExceeded_HL = 86, + + /// Uncertain – engineering units exceeded, constant. + Uncertain_EuExceeded_C = 87, + + /// Uncertain – sub-normal operating conditions. + Uncertain_SubNormal = 88, + + /// Uncertain – sub-normal, low limited. + Uncertain_SubNormal_LL = 89, + + /// Uncertain – sub-normal, high limited. + Uncertain_SubNormal_HL = 90, + + /// Uncertain – sub-normal, constant. + Uncertain_SubNormal_C = 91, + + /// Good – non-specific. + Good = 192, + + /// Good – low limited. + Good_LowLimited = 193, + + /// Good – high limited. + Good_HighLimited = 194, + + /// Good – constant. + Good_Constant = 195, + + /// Good – local override active. + Good_LocalOverride = 216, + + /// Good – local override active, low limited. + Good_LocalOverride_LL = 217, + + /// Good – local override active, high limited. + Good_LocalOverride_HL = 218, + + /// Good – local override active, constant. + Good_LocalOverride_C = 219 +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs new file mode 100644 index 0000000..9f87647 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs @@ -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; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs new file mode 100644 index 0000000..bebfbc4 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs @@ -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 +// ──────────────────────────────────────────────────────────────── + +/// +/// Code-first gRPC service contract for SCADA operations. +/// +[ServiceContract(Name = "scada.ScadaService")] +public interface IScadaService +{ + /// Establishes a connection with the SCADA service. + ValueTask ConnectAsync(ConnectRequest request); + + /// Terminates a SCADA service connection. + ValueTask DisconnectAsync(DisconnectRequest request); + + /// Retrieves the current state of a SCADA connection. + ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); + + /// Reads a single tag value from the SCADA system. + ValueTask ReadAsync(ReadRequest request); + + /// Reads multiple tag values from the SCADA system in a batch operation. + ValueTask ReadBatchAsync(ReadBatchRequest request); + + /// Writes a single value to a tag in the SCADA system. + ValueTask WriteAsync(WriteRequest request); + + /// Writes multiple values to tags in the SCADA system in a batch operation. + ValueTask WriteBatchAsync(WriteBatchRequest request); + + /// Writes multiple values and waits for a completion flag before returning. + ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); + + /// Subscribes to real-time value changes from specified tags. + IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); + + /// Validates an API key for authentication. + ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); +} + +// ──────────────────────────────────────────────────────────────── +// VTQ message +// ──────────────────────────────────────────────────────────────── + +/// +/// Value-Timestamp-Quality message transmitted over gRPC. +/// All values are string-encoded; timestamps are UTC ticks. +/// +[DataContract] +public class VtqMessage +{ + /// Tag address. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Value encoded as a string. + [DataMember(Order = 2)] + public string Value { get; set; } = string.Empty; + + /// UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01). + [DataMember(Order = 3)] + public long TimestampUtcTicks { get; set; } + + /// Quality string: "Good", "Uncertain", or "Bad". + [DataMember(Order = 4)] + public string Quality { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// Connect +// ──────────────────────────────────────────────────────────────── + +/// Request to establish a session with the proxy server. +[DataContract] +public class ConnectRequest +{ + /// Client identifier (e.g., "ScadaLink-{guid}"). + [DataMember(Order = 1)] + public string ClientId { get; set; } = string.Empty; + + /// API key for authentication (empty if none required). + [DataMember(Order = 2)] + public string ApiKey { get; set; } = string.Empty; +} + +/// Response from a Connect call. +[DataContract] +public class ConnectResponse +{ + /// Whether the connection was established successfully. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Session ID (32-char hex GUID). Only valid when is true. + [DataMember(Order = 3)] + public string SessionId { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// Disconnect +// ──────────────────────────────────────────────────────────────── + +/// Request to terminate a session. +[DataContract] +public class DisconnectRequest +{ + /// Active session ID to disconnect. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +/// Response from a Disconnect call. +[DataContract] +public class DisconnectResponse +{ + /// Whether the disconnect succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// GetConnectionState +// ──────────────────────────────────────────────────────────────── + +/// Request to query connection state for a session. +[DataContract] +public class GetConnectionStateRequest +{ + /// Session ID to query. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +/// Response with connection state information. +[DataContract] +public class GetConnectionStateResponse +{ + /// Whether the session is currently connected. + [DataMember(Order = 1)] + public bool IsConnected { get; set; } + + /// Client identifier for this session. + [DataMember(Order = 2)] + public string ClientId { get; set; } = string.Empty; + + /// UTC ticks when the connection was established. + [DataMember(Order = 3)] + public long ConnectedSinceUtcTicks { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Read +// ──────────────────────────────────────────────────────────────── + +/// Request to read a single tag. +[DataContract] +public class ReadRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag address to read. + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; +} + +/// Response from a single-tag Read call. +[DataContract] +public class ReadResponse +{ + /// Whether the read succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Error message if the read failed. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// The value-timestamp-quality result. + [DataMember(Order = 3)] + public VtqMessage? Vtq { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// ReadBatch +// ──────────────────────────────────────────────────────────────── + +/// Request to read multiple tags in a single round-trip. +[DataContract] +public class ReadBatchRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag addresses to read. + [DataMember(Order = 2)] + public List Tags { get; set; } = []; +} + +/// Response from a batch Read call. +[DataContract] +public class ReadBatchResponse +{ + /// False if any tag read failed. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// VTQ results in the same order as the request tags. + [DataMember(Order = 3)] + public List Vtqs { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// Write +// ──────────────────────────────────────────────────────────────── + +/// Request to write a single tag value. +[DataContract] +public class WriteRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag address to write. + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; + + /// Value as a string (parsed server-side). + [DataMember(Order = 3)] + public string Value { get; set; } = string.Empty; +} + +/// Response from a single-tag Write call. +[DataContract] +public class WriteResponse +{ + /// Whether the write succeeded. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// WriteItem / WriteResult +// ──────────────────────────────────────────────────────────────── + +/// A single tag-value pair for batch write operations. +[DataContract] +public class WriteItem +{ + /// Tag address. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Value as a string. + [DataMember(Order = 2)] + public string Value { get; set; } = string.Empty; +} + +/// Per-item result from a batch write operation. +[DataContract] +public class WriteResult +{ + /// Tag address that was written. + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + /// Whether the individual write succeeded. + [DataMember(Order = 2)] + public bool Success { get; set; } + + /// Error message for this item, if any. + [DataMember(Order = 3)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// WriteBatch +// ──────────────────────────────────────────────────────────────── + +/// Request to write multiple tag values in a single round-trip. +[DataContract] +public class WriteBatchRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag-value pairs to write. + [DataMember(Order = 2)] + public List Items { get; set; } = []; +} + +/// Response from a batch Write call. +[DataContract] +public class WriteBatchResponse +{ + /// Overall success — false if any item failed. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Per-item write results. + [DataMember(Order = 3)] + public List Results { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// WriteBatchAndWait +// ──────────────────────────────────────────────────────────────── + +/// +/// Request to write multiple tag values then poll a flag tag +/// until it matches an expected value or the timeout expires. +/// +[DataContract] +public class WriteBatchAndWaitRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag-value pairs to write. + [DataMember(Order = 2)] + public List Items { get; set; } = []; + + /// Tag to poll after writes complete. + [DataMember(Order = 3)] + public string FlagTag { get; set; } = string.Empty; + + /// Expected value for the flag tag (string comparison). + [DataMember(Order = 4)] + public string FlagValue { get; set; } = string.Empty; + + /// Timeout in milliseconds (default 5000 if <= 0). + [DataMember(Order = 5)] + public int TimeoutMs { get; set; } + + /// Poll interval in milliseconds (default 100 if <= 0). + [DataMember(Order = 6)] + public int PollIntervalMs { get; set; } +} + +/// Response from a WriteBatchAndWait call. +[DataContract] +public class WriteBatchAndWaitResponse +{ + /// Overall operation success. + [DataMember(Order = 1)] + public bool Success { get; set; } + + /// Status or error message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + /// Per-item write results. + [DataMember(Order = 3)] + public List WriteResults { get; set; } = []; + + /// Whether the flag tag matched the expected value before timeout. + [DataMember(Order = 4)] + public bool FlagReached { get; set; } + + /// Total elapsed time in milliseconds. + [DataMember(Order = 5)] + public int ElapsedMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Subscribe +// ──────────────────────────────────────────────────────────────── + +/// Request to subscribe to value change notifications on one or more tags. +[DataContract] +public class SubscribeRequest +{ + /// Valid session ID. + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + /// Tag addresses to monitor. + [DataMember(Order = 2)] + public List Tags { get; set; } = []; + + /// Backend sampling interval in milliseconds. + [DataMember(Order = 3)] + public int SamplingMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// CheckApiKey +// ──────────────────────────────────────────────────────────────── + +/// Request to validate an API key without creating a session. +[DataContract] +public class CheckApiKeyRequest +{ + /// API key to validate. + [DataMember(Order = 1)] + public string ApiKey { get; set; } = string.Empty; +} + +/// Response from an API key validation check. +[DataContract] +public class CheckApiKeyResponse +{ + /// Whether the API key is valid. + [DataMember(Order = 1)] + public bool IsValid { get; set; } + + /// Validation message. + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs new file mode 100644 index 0000000..34b007a --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs @@ -0,0 +1,27 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// Value, Timestamp, and Quality structure for SCADA data. +/// +/// The value. +/// The timestamp when the value was read. +/// The quality of the value. +public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) +{ + /// Creates a new VTQ with the specified value and quality, using the current UTC timestamp. + public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); + + /// Creates a new VTQ with the specified value, timestamp, and quality. + public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); + + /// Creates a Good-quality VTQ with the current UTC time. + public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good); + + /// Creates a Bad-quality VTQ with the current UTC time. + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + + /// Creates an Uncertain-quality VTQ with the current UTC time. + public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain); +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs new file mode 100644 index 0000000..9e211c5 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs @@ -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 +{ + /// + /// Interface for LmxProxy client operations + /// + public interface ILmxProxyClient : IDisposable, IAsyncDisposable + { + /// + /// Gets or sets the default timeout for operations + /// + TimeSpan DefaultTimeout { get; set; } + + /// + /// Connects to the LmxProxy service + /// + /// Cancellation token. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + /// Disconnects from the LmxProxy service + /// + Task DisconnectAsync(); + + /// + /// Checks if the client is connected to the service + /// + Task IsConnectedAsync(); + + /// + /// Reads a single tag value + /// + /// The tag address to read. + /// Cancellation token. + Task ReadAsync(string address, CancellationToken cancellationToken = default); + + /// + /// Reads multiple tag values in a single batch + /// + /// The tag addresses to read. + /// Cancellation token. + Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); + + /// + /// Writes a single tag value + /// + /// The tag address to write. + /// The value to write. + /// Cancellation token. + Task WriteAsync(string address, object value, CancellationToken cancellationToken = default); + + /// + /// Writes multiple tag values in a single batch + /// + /// The tag addresses and values to write. + /// Cancellation token. + Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); + + /// + /// Subscribes to tag updates + /// + /// The tag addresses to subscribe to. + /// Callback invoked when tag values change. + /// Cancellation token. + Task SubscribeAsync(IEnumerable addresses, Action onUpdate, CancellationToken cancellationToken = default); + + /// + /// Gets the current metrics snapshot + /// + Dictionary GetMetrics(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs new file mode 100644 index 0000000..c006b9c --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// Factory interface for creating LmxProxyClient instances + /// + public interface ILmxProxyClientFactory + { + /// + /// Creates a new LmxProxyClient instance with default configuration + /// + /// A configured LmxProxyClient instance + LmxProxyClient CreateClient(); + + /// + /// Creates a new LmxProxyClient instance with custom configuration + /// + /// Name of the configuration section to use + /// A configured LmxProxyClient instance + LmxProxyClient CreateClient(string configurationName); + + /// + /// Creates a new LmxProxyClient instance using a builder + /// + /// Action to configure the builder + /// A configured LmxProxyClient instance + LmxProxyClient CreateClient(Action builderAction); + } + + /// + /// Default implementation of ILmxProxyClientFactory + /// + public class LmxProxyClientFactory : ILmxProxyClientFactory + { + private readonly IConfiguration _configuration; + + /// + /// Initializes a new instance of the LmxProxyClientFactory + /// + /// Application configuration + public LmxProxyClientFactory(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + /// + /// Creates a new LmxProxyClient instance with default configuration + /// + /// A configured LmxProxyClient instance + public LmxProxyClient CreateClient() + { + return CreateClient("LmxProxy"); + } + + /// + /// Creates a new LmxProxyClient instance with custom configuration + /// + /// Name of the configuration section to use + /// A configured LmxProxyClient instance + 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("UseSsl"); + if (useSsl) + { + string? certificatePath = section["CertificatePath"]; + builder.WithSslCredentials(certificatePath); + } + + // Metrics configuration + if (section.GetValue("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(); + } + + /// + /// Creates a new LmxProxyClient instance using a builder + /// + /// Action to configure the builder + /// A configured LmxProxyClient instance + public LmxProxyClient CreateClient(Action builderAction) + { + ArgumentNullException.ThrowIfNull(builderAction); + + var builder = new LmxProxyClientBuilder(); + builderAction(builder); + + // Logger is optional - caller can set it via builderAction if needed + + return builder.Build(); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs new file mode 100644 index 0000000..d184e59 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs @@ -0,0 +1,36 @@ +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// API key information returned from CheckApiKey + /// + public class ApiKeyInfo + { + /// + /// Whether the API key is valid + /// + public bool IsValid { get; } + + /// + /// The role assigned to the API key + /// + public string Role { get; } + + /// + /// Description of the API key + /// + public string Description { get; } + + /// + /// Initializes a new instance of the ApiKeyInfo class + /// + /// Whether the API key is valid + /// The role assigned to the API key + /// Description of the API key + public ApiKeyInfo(bool isValid, string role, string description) + { + IsValid = isValid; + Role = role ?? string.Empty; + Description = description ?? string.Empty; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs new file mode 100644 index 0000000..3dd6600 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// Metrics collection for client operations + /// + internal class ClientMetrics + { + private readonly ConcurrentDictionary _operationCounts = new(); + private readonly ConcurrentDictionary _errorCounts = new(); + private readonly ConcurrentDictionary> _latencies = new(); + private readonly object _latencyLock = new(); + + /// + /// Increments the operation count for a specific operation. + /// + /// The operation name. + public void IncrementOperationCount(string operation) + { + _operationCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1); + } + + /// + /// Increments the error count for a specific operation. + /// + /// The operation name. + public void IncrementErrorCount(string operation) + { + _errorCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1); + } + + /// + /// Records latency for a specific operation. + /// + /// The operation name. + /// The latency in milliseconds. + 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); + } + } + } + + /// + /// Gets a snapshot of current metrics. + /// + /// A dictionary containing metric data. + public Dictionary GetSnapshot() + { + var snapshot = new Dictionary(); + + foreach (KeyValuePair kvp in _operationCounts) + { + snapshot[$"{kvp.Key}_count"] = kvp.Value; + } + + foreach (KeyValuePair kvp in _errorCounts) + { + snapshot[$"{kvp.Key}_errors"] = kvp.Value; + } + + lock (_latencyLock) + { + foreach (KeyValuePair> 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 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)]; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs new file mode 100644 index 0000000..02e84df --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs @@ -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 _tags; + private readonly Action _onUpdate; + private readonly ILogger _logger; + private readonly Action? _onDispose; + private readonly CancellationTokenSource _cts = new(); + private Task? _processingTask; + private bool _disposed; + + /// + /// Initializes a new instance of the CodeFirstSubscription class. + /// + /// The gRPC ScadaService client. + /// The session identifier. + /// The list of tag addresses to subscribe to. + /// Callback invoked when tag values change. + /// Logger for diagnostic information. + /// Optional callback invoked when the subscription is disposed. + public CodeFirstSubscription( + IScadaService client, + string sessionId, + List tags, + Action onUpdate, + ILogger logger, + Action? onDispose = null) + { + _client = client; + _sessionId = sessionId; + _tags = tags; + _onUpdate = onUpdate; + _logger = logger; + _onDispose = onDispose; + } + + /// + /// Starts the subscription asynchronously and begins processing tag value updates. + /// + /// Cancellation token. + /// A task that completes when the subscription processing has started. + 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); + } + } + } + + /// + /// Asynchronously disposes the subscription and stops processing tag updates. + /// + /// A task representing the asynchronous disposal operation. + 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); + } + } + + /// + /// Synchronously disposes the subscription and stops processing tag updates. + /// + 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"); + } + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs new file mode 100644 index 0000000..ead33f5 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs @@ -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 + { + /// + /// Connects to the LmxProxy service and establishes a session + /// + /// Cancellation token. + 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(); + + // 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; + } + + /// + /// Disconnects from the LmxProxy service + /// + 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(); + } + } + + /// + /// Connects the LmxProxy to MxAccess (legacy method - session now established in ConnectAsync) + /// + /// Cancellation token. + 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.")); + } + + /// + /// Disconnects the LmxProxy from MxAccess (legacy method) + /// + /// Cancellation token. + public async Task<(bool Success, string? ErrorMessage)> DisconnectFromMxAccessAsync(CancellationToken cancellationToken = default) + { + try + { + await DisconnectAsync(); + return (true, null); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + /// + /// Gets the connection state of the LmxProxy + /// + /// Cancellation token. + 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); + } + + /// + /// Builds the gRPC endpoint URI (http/https) based on TLS configuration. + /// + 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 subsToDispose; + lock (_subscriptionLock) + { + subsToDispose = new List(_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"); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs new file mode 100644 index 0000000..3d909a5 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// Represents a subscription to tag value changes + /// + public interface ISubscription : IDisposable + { + /// + /// Disposes the subscription asynchronously + /// + Task DisposeAsync(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs new file mode 100644 index 0000000..10a2164 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs @@ -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 +{ + /// + /// Client for communicating with the LmxProxy gRPC service using protobuf-net.Grpc code-first + /// + public partial class LmxProxyClient : ILmxProxyClient + { + private static readonly string Http2InsecureSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport"; + private readonly ILogger _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 _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); + } + + /// + /// Gets or sets the default timeout for operations + /// + 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; + } + } + + /// + /// Initializes a new instance of the LmxProxyClient + /// + /// The host address of the LmxProxy service + /// The port of the LmxProxy service + /// The API key for authentication + /// Optional logger instance + public LmxProxyClient(string host, int port, string? apiKey = null, ILogger? logger = null) + : this(host, port, apiKey, null, logger) + { + } + + /// + /// Creates a new instance of the LmxProxyClient with TLS configuration + /// + /// The host address of the LmxProxy service + /// The port of the LmxProxy service + /// The API key for authentication + /// TLS configuration for secure connections + /// Optional logger instance + public LmxProxyClient(string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger? 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.Instance; + } + + /// + /// Gets whether the client is connected to the service + /// + public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); + + /// + /// Asynchronously checks if the client is connected with proper synchronization + /// + public async Task IsConnectedAsync() + { + await _connectionLock.WaitAsync(); + try + { + return !_disposed && _client != null && _isConnected && !string.IsNullOrEmpty(_sessionId); + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Sets the builder configuration (internal use) + /// + /// The client configuration. + internal void SetBuilderConfiguration(ClientConfiguration configuration) + { + _configuration = configuration; + + // Setup retry policy if configured + if (configuration.MaxRetryAttempts > 0) + { + _retryPolicy = Policy + .Handle(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); + }); + } + } + + /// + /// Reads a single tag value + /// + /// The tag address to read. + /// Cancellation token. + public async Task 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; + } + } + + /// + /// Reads multiple tag values + /// + /// The tag addresses to read. + /// Cancellation token. + public async Task> ReadBatchAsync(IEnumerable 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(); + foreach (VtqMessage vtq in response.Vtqs) + { + results[vtq.Tag] = ConvertToVtq(vtq.Tag, vtq); + } + return results; + } + + /// + /// Writes a single tag value + /// + /// The tag address to write. + /// The value to write. + /// Cancellation token. + 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}"); + } + + /// + /// Writes multiple tag values + /// + /// The tag addresses and values to write. + /// Cancellation token. + public async Task WriteBatchAsync(IDictionary 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}"); + } + + /// + /// Writes values and waits for a condition to be met + /// + /// The tag addresses and values to write. + /// The flag address to write. + /// The flag value to write. + /// The response address to monitor. + /// The expected response value. + /// Timeout in seconds. + /// Cancellation token. + public async Task WriteBatchAndWaitAsync( + IDictionary 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; + } + + /// + /// Checks the validity and permissions of the current API key + /// + /// Cancellation token. + public async Task 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); + } + + /// + /// Subscribes to tag value changes + /// + /// The tag addresses to subscribe to. + /// Callback invoked when tag values change. + /// Cancellation token. + public Task SubscribeAsync( + IEnumerable addresses, + Action onUpdate, + CancellationToken cancellationToken = default) + { + List 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(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 + }; + } + + /// + /// Removes a subscription from the active tracking list + /// + private void RemoveSubscription(ISubscription subscription) + { + lock (_subscriptionLock) + { + _activeSubscriptions.Remove(subscription); + } + } + + /// + /// Disposes of the client and closes the connection + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + DisposeAsync().AsTask().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } + + /// + /// Asynchronously disposes of the client and closes the connection + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + + await DisposeCoreAsync().ConfigureAwait(false); + _connectionLock.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Protected disposal implementation + /// + /// True if disposing managed resources. + protected virtual void Dispose(bool disposing) + { + if (!disposing || _disposed) + return; + + _disposed = true; + + DisposeCoreAsync().GetAwaiter().GetResult(); + _connectionLock.Dispose(); + } + + private async Task DisposeCoreAsync() + { + StopKeepAlive(); + + List subscriptionsToDispose; + lock (_subscriptionLock) + { + subscriptionsToDispose = new List(_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 ExecuteWithRetryAsync(Func> operation, string correlationId) + { + if (_retryPolicy != null) + { + var context = new Context { ["CorrelationId"] = correlationId }; + return await _retryPolicy.ExecuteAsync(async (_) => await operation(), context); + } + + return await operation(); + } + + /// + /// Gets the current metrics snapshot + /// + public Dictionary GetMetrics() => _metrics.GetSnapshot(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs new file mode 100644 index 0000000..cd4342c --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs @@ -0,0 +1,241 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// Builder for creating configured instances of LmxProxyClient + /// + public class LmxProxyClientBuilder + { + private string? _host; + private int _port = 5050; + private string? _apiKey; + private ILogger? _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; + + /// + /// Sets the host address for the LmxProxy service + /// + /// The host address + /// The builder instance for method chaining + public LmxProxyClientBuilder WithHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + throw new ArgumentException("Host cannot be null or empty", nameof(host)); + + _host = host; + return this; + } + + /// + /// Sets the port for the LmxProxy service + /// + /// The port number + /// The builder instance for method chaining + 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; + } + + /// + /// Sets the API key for authentication + /// + /// The API key + /// The builder instance for method chaining + public LmxProxyClientBuilder WithApiKey(string apiKey) + { + _apiKey = apiKey; + return this; + } + + /// + /// Sets the logger instance + /// + /// The logger + /// The builder instance for method chaining + public LmxProxyClientBuilder WithLogger(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + return this; + } + + /// + /// Sets the default timeout for operations + /// + /// The timeout duration + /// The builder instance for method chaining + 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; + } + + /// + /// Enables SSL/TLS with the specified certificate + /// + /// Path to the certificate file + /// The builder instance for method chaining + public LmxProxyClientBuilder WithSslCredentials(string? certificatePath = null) + { + _tlsConfiguration ??= new ClientTlsConfiguration(); + _tlsConfiguration.UseTls = true; + _tlsConfiguration.ServerCaCertificatePath = string.IsNullOrWhiteSpace(certificatePath) ? null : certificatePath; + return this; + } + + /// + /// Applies a full TLS configuration to the client. + /// + /// The TLS configuration to apply. + /// The builder instance for method chaining. + public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration configuration) + { + _tlsConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + return this; + } + + /// + /// Sets the retry configuration + /// + /// Maximum number of retry attempts + /// Delay between retries + /// The builder instance for method chaining + 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; + } + + /// + /// Enables metrics collection + /// + /// The builder instance for method chaining + public LmxProxyClientBuilder WithMetrics() + { + _enableMetrics = true; + return this; + } + + /// + /// Sets the correlation ID header name for request tracing + /// + /// The header name for correlation ID + /// The builder instance for method chaining + 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; + } + + /// + /// Builds the configured LmxProxyClient instance + /// + /// A configured LmxProxyClient instance + 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); + } + } + } + + /// + /// Internal configuration class for storing builder settings + /// + internal class ClientConfiguration + { + /// + /// Gets or sets the maximum number of retry attempts. + /// + public int MaxRetryAttempts { get; set; } + + /// + /// Gets or sets the retry delay. + /// + public TimeSpan RetryDelay { get; set; } + + /// + /// Gets or sets a value indicating whether metrics are enabled. + /// + public bool EnableMetrics { get; set; } + + /// + /// Gets or sets the correlation ID header name. + /// + public string? CorrelationIdHeader { get; set; } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..9157023 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// Expose internal members to test assembly +[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")] diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs new file mode 100644 index 0000000..9c4443b --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs @@ -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); + } + + /// + /// Creates a gRPC channel with optional TLS configuration. + /// + /// The server address. + /// Optional TLS configuration. + /// The logger. + /// A configured gRPC channel. + 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)); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..918e53d --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,182 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.LmxProxy.Client +{ + /// + /// Extension methods for registering LmxProxyClient with dependency injection + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds LmxProxyClient services to the service collection + /// + /// The service collection + /// Application configuration + /// The service collection for chaining + public static IServiceCollection AddLmxProxyClient(this IServiceCollection services, IConfiguration configuration) + { + return services.AddLmxProxyClient(configuration, "LmxProxy"); + } + + /// + /// Adds LmxProxyClient services to the service collection with a specific configuration section + /// + /// The service collection + /// Application configuration + /// Name of the configuration section + /// The service collection for chaining + public static IServiceCollection AddLmxProxyClient( + this IServiceCollection services, + IConfiguration configuration, + string configurationSection) + { + services.AddSingleton(); + + // Register a singleton client with default configuration + services.AddSingleton(provider => + { + ILmxProxyClientFactory factory = provider.GetRequiredService(); + return factory.CreateClient(configurationSection); + }); + + return services; + } + + /// + /// Adds LmxProxyClient services to the service collection with custom configuration + /// + /// The service collection + /// Action to configure the client builder + /// The service collection for chaining + public static IServiceCollection AddLmxProxyClient( + this IServiceCollection services, + Action configureClient) + { + services.AddSingleton(); + + // Register a singleton client with custom configuration + services.AddSingleton(provider => + { + ILmxProxyClientFactory factory = provider.GetRequiredService(); + return factory.CreateClient(configureClient); + }); + + return services; + } + + /// + /// Adds LmxProxyClient services to the service collection with scoped lifetime + /// + /// The service collection + /// Application configuration + /// The service collection for chaining + public static IServiceCollection AddScopedLmxProxyClient( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddSingleton(); + + // Register a scoped client + services.AddScoped(provider => + { + ILmxProxyClientFactory factory = provider.GetRequiredService(); + return factory.CreateClient(); + }); + + return services; + } + + /// + /// Adds named LmxProxyClient services to the service collection + /// + /// The service collection + /// Name for the client + /// Action to configure the client builder + /// The service collection for chaining + public static IServiceCollection AddNamedLmxProxyClient( + this IServiceCollection services, + string name, + Action configureClient) + { + services.AddSingleton(); + + // Register a keyed singleton + services.AddKeyedSingleton(name, (provider, _) => + { + ILmxProxyClientFactory factory = provider.GetRequiredService(); + return factory.CreateClient(configureClient); + }); + + return services; + } + } + + /// + /// Configuration options for LmxProxyClient + /// + public class LmxProxyClientOptions + { + /// + /// Gets or sets the host address + /// + public string Host { get; set; } = "localhost"; + + /// + /// Gets or sets the port number + /// + public int Port { get; set; } = 5050; + + /// + /// Gets or sets the API key + /// + public string? ApiKey { get; set; } + + /// + /// Gets or sets the timeout duration + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets whether to use SSL + /// + public bool UseSsl { get; set; } + + /// + /// Gets or sets the certificate path for SSL + /// + public string? CertificatePath { get; set; } + + /// + /// Gets or sets whether to enable metrics + /// + public bool EnableMetrics { get; set; } + + /// + /// Gets or sets the correlation ID header name + /// + public string? CorrelationIdHeader { get; set; } + + /// + /// Gets or sets the retry configuration + /// + public RetryOptions? Retry { get; set; } + } + + /// + /// Retry configuration options + /// + public class RetryOptions + { + /// + /// Gets or sets the maximum number of retry attempts + /// + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the delay between retries + /// + public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs new file mode 100644 index 0000000..c36f5e6 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs @@ -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 +{ + /// + /// Extension methods for streaming operations with the LmxProxy client + /// + public static class StreamingExtensions + { + /// + /// Reads multiple tag values as an async stream for efficient memory usage with large datasets + /// + /// The LmxProxy client + /// The addresses to read + /// Size of each batch to process + /// Cancellation token + /// An async enumerable of tag values + public static async IAsyncEnumerable> ReadStreamAsync( + this ILmxProxyClient client, + IEnumerable 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(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? 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 result in results) + { + yield return result; + } + batch.Clear(); + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + } + + // Process remaining items + if (batch.Count > 0) + { + IDictionary? 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 result in results) + { + yield return result; + } + } + } + } + + /// + /// Writes multiple tag values as an async stream for efficient memory usage with large datasets + /// + /// The LmxProxy client + /// The values to write as an async enumerable + /// Size of each batch to process + /// Cancellation token + /// The number of values written + public static async Task WriteStreamAsync( + this ILmxProxyClient client, + IAsyncEnumerable> 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(batchSize); + int totalWritten = 0; + + await foreach (KeyValuePair 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; + } + + /// + /// Processes tag values in parallel batches for maximum throughput + /// + /// The LmxProxy client + /// The addresses to read + /// The async function to process each value + /// Maximum number of concurrent operations + /// Cancellation token + public static async Task ProcessInParallelAsync( + this ILmxProxyClient client, + IEnumerable addresses, + Func 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(); + + await foreach (KeyValuePair 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); + } + + /// + /// Subscribes to multiple tags and returns updates as an async stream + /// + /// The LmxProxy client + /// The addresses to subscribe to + /// Poll interval in milliseconds + /// Cancellation token + /// An async enumerable of tag updates + public static async IAsyncEnumerable SubscribeStreamAsync( + this ILmxProxyClient client, + IEnumerable addresses, + int pollIntervalMs = 1000, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(addresses); + + var updateChannel = System.Threading.Channels.Channel.CreateUnbounded(); + + // 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(); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj new file mode 100644 index 0000000..5f65bc3 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + ZB.MOM.WW.LmxProxy.Client + ZB.MOM.WW.LmxProxy.Client + true + true + gRPC client library for LmxProxy service + AnyCPU + AnyCPU + + + + + + + + + + + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/App.config b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/App.config new file mode 100644 index 0000000..ee29aaa --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/App.config @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs new file mode 100644 index 0000000..286e152 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Validates LmxProxy configuration settings on startup. + /// + public static class ConfigurationValidator + { + private static readonly ILogger Logger = Log.ForContext(typeof(ConfigurationValidator)); + + /// + /// Validates the provided configuration and returns a list of validation errors. + /// + /// The configuration to validate. + /// A list of validation error messages. Empty if configuration is valid. + public static List Validate(LmxProxyConfiguration configuration) + { + var errors = new List(); + + 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 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 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 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."); + } + } + + /// + /// Logs validation results and returns whether the configuration is valid. + /// + /// The configuration to validate. + /// True if configuration is valid; otherwise, false. + public static bool ValidateAndLog(LmxProxyConfiguration configuration) + { + List 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; + } + + /// + /// Throws an exception if the configuration is invalid. + /// + /// The configuration to validate. + /// Thrown when configuration is invalid. + public static void ValidateOrThrow(LmxProxyConfiguration configuration) + { + List 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); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs new file mode 100644 index 0000000..cebc9d1 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs @@ -0,0 +1,110 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Configuration settings for LmxProxy service + /// + public class LmxProxyConfiguration + { + /// + /// gRPC server port + /// + public int GrpcPort { get; set; } = 50051; + + /// + /// Subscription management settings + /// + public SubscriptionConfiguration Subscription { get; set; } = new(); + + /// + /// Windows service recovery settings + /// + public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new(); + + /// + /// API key configuration file path + /// + public string ApiKeyConfigFile { get; set; } = "apikeys.json"; + + /// + /// MxAccess connection settings + /// + public ConnectionConfiguration Connection { get; set; } = new(); + + /// + /// TLS/SSL configuration for secure gRPC communication + /// + public TlsConfiguration Tls { get; set; } = new(); + + /// + /// Web server configuration for status display + /// + public WebServerConfiguration WebServer { get; set; } = new(); + } + + /// + /// Configuration for MxAccess connection monitoring and reconnection + /// + public class ConnectionConfiguration + { + /// + /// Interval in seconds between connection health checks + /// + public int MonitorIntervalSeconds { get; set; } = 5; + + /// + /// Timeout in seconds for initial connection attempts + /// + public int ConnectionTimeoutSeconds { get; set; } = 30; + + /// + /// Whether to automatically reconnect when connection is lost + /// + public bool AutoReconnect { get; set; } = true; + + /// + /// Timeout in seconds for read operations + /// + public int ReadTimeoutSeconds { get; set; } = 5; + + /// + /// Timeout in seconds for write operations + /// + public int WriteTimeoutSeconds { get; set; } = 5; + + /// + /// Maximum number of concurrent read/write operations allowed + /// + public int? MaxConcurrentOperations { get; set; } = 10; + + /// + /// Name of the node to connect to (optional) + /// + public string? NodeName { get; set; } + + /// + /// Name of the galaxy to connect to (optional) + /// + public string? GalaxyName { get; set; } + } + + /// + /// Configuration for web server that displays status information + /// + public class WebServerConfiguration + { + /// + /// Whether the web server is enabled + /// + public bool Enabled { get; set; } = true; + + /// + /// Port number for the web server + /// + public int Port { get; set; } = 8080; + + /// + /// Prefix URL for the web server (default: http://+:{Port}/) + /// + public string? Prefix { get; set; } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs new file mode 100644 index 0000000..5ca4ded --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Configuration for Windows service recovery + /// + public class ServiceRecoveryConfiguration + { + /// + /// Minutes to wait before restart on first failure + /// + public int FirstFailureDelayMinutes { get; set; } = 1; + + /// + /// Minutes to wait before restart on second failure + /// + public int SecondFailureDelayMinutes { get; set; } = 5; + + /// + /// Minutes to wait before restart on subsequent failures + /// + public int SubsequentFailureDelayMinutes { get; set; } = 10; + + /// + /// Days before resetting the failure count + /// + public int ResetPeriodDays { get; set; } = 1; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs new file mode 100644 index 0000000..1637c4a --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Configuration for subscription management + /// + public class SubscriptionConfiguration + { + /// + /// Buffer size for each client's channel (number of messages) + /// + public int ChannelCapacity { get; set; } = 1000; + + /// + /// Strategy when channel buffer is full: "DropOldest", "DropNewest", or "Wait" + /// + public string ChannelFullMode { get; set; } = "DropOldest"; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs new file mode 100644 index 0000000..56b278e --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs @@ -0,0 +1,90 @@ +using System.IO; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Configuration for TLS/SSL settings for secure gRPC communication + /// + public class TlsConfiguration + { + /// + /// Gets or sets whether TLS is enabled for gRPC communication + /// + public bool Enabled { get; set; } = false; + + /// + /// Gets or sets the path to the server certificate file (.pem or .crt) + /// + public string ServerCertificatePath { get; set; } = string.Empty; + + /// + /// Gets or sets the path to the server private key file (.key) + /// + public string ServerKeyPath { get; set; } = string.Empty; + + /// + /// Gets or sets the path to the certificate authority file for client certificate validation (optional) + /// + public string? ClientCaCertificatePath { get; set; } + + /// + /// Gets or sets whether to require client certificates for mutual TLS + /// + public bool RequireClientCertificate { get; set; } = false; + + /// + /// Gets or sets whether to check certificate revocation + /// + public bool CheckCertificateRevocation { get; set; } = true; + + /// + /// Validates the TLS configuration + /// + /// True if configuration is valid, false otherwise + 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; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs new file mode 100644 index 0000000..2d060f7 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Per-client subscription statistics. + /// + public class ClientStats + { + /// + /// Gets or sets the number of tags the client is subscribed to. + /// + public int SubscribedTags { get; set; } + + /// + /// Gets or sets the number of delivered messages. + /// + public long DeliveredMessages { get; set; } + + /// + /// Gets or sets the number of dropped messages. + /// + public long DroppedMessages { get; set; } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs new file mode 100644 index 0000000..ed1e37e --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Represents the state of a SCADA client connection. + /// + public enum ConnectionState + { + /// + /// The client is disconnected. + /// + Disconnected, + + /// + /// The client is in the process of connecting. + /// + Connecting, + + /// + /// The client is connected. + /// + Connected, + + /// + /// The client is in the process of disconnecting. + /// + Disconnecting, + + /// + /// The client encountered an error. + /// + Error, + + /// + /// The client is reconnecting after a connection loss. + /// + Reconnecting + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs new file mode 100644 index 0000000..0549b1b --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs @@ -0,0 +1,45 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Event arguments for SCADA client connection state changes. + /// + public class ConnectionStateChangedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The previous connection state. + /// The current connection state. + /// Optional message providing additional information about the state change. + public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, + string? message = null) + { + PreviousState = previousState; + CurrentState = currentState; + Timestamp = DateTime.UtcNow; + Message = message; + } + + /// + /// Gets the previous connection state. + /// + public ConnectionState PreviousState { get; } + + /// + /// Gets the current connection state. + /// + public ConnectionState CurrentState { get; } + + /// + /// Gets the timestamp when the state change occurred. + /// + public DateTime Timestamp { get; } + + /// + /// Gets additional information about the state change, such as error messages. + /// + public string? Message { get; } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs new file mode 100644 index 0000000..1d16dc7 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Interface for SCADA system clients. + /// + public interface IScadaClient : IAsyncDisposable + { + /// + /// Gets the connection status. + /// + bool IsConnected { get; } + + /// + /// Gets the current connection state. + /// + ConnectionState ConnectionState { get; } + + /// + /// Occurs when the connection state changes. + /// + event EventHandler ConnectionStateChanged; + + /// + /// Connects to the SCADA system. + /// + /// Cancellation token. + Task ConnectAsync(CancellationToken ct = default); + + /// + /// Disconnects from the SCADA system. + /// + /// Cancellation token. + Task DisconnectAsync(CancellationToken ct = default); + + /// + /// Reads a single tag value from the SCADA system. + /// + /// The tag address. + /// Cancellation token. + /// The value, timestamp, and quality. + Task ReadAsync(string address, CancellationToken ct = default); + + /// + /// Reads multiple tag values from the SCADA system. + /// + /// The tag addresses. + /// Cancellation token. + /// Dictionary of address to VTQ values. + Task> + ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); + + /// + /// Writes a single tag value to the SCADA system. + /// + /// The tag address. + /// The value to write. + /// Cancellation token. + Task WriteAsync(string address, object value, CancellationToken ct = default); + + /// + /// Writes multiple tag values to the SCADA system. + /// + /// Dictionary of address to value. + /// Cancellation token. + Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); + + /// + /// Writes a batch of tag values and a flag tag, then waits for a response tag to + /// equal the expected value. + /// + /// The regular tag values to write. + /// The address of the flag tag to write. + /// The value to write to the flag tag. + /// The address of the response tag to monitor. + /// The expected value of the response tag. + /// Cancellation token controlling the wait. + /// + /// true if the response value was observed before cancellation; + /// otherwise false. + /// + Task WriteBatchAndWaitAsync( + IReadOnlyDictionary values, + string flagAddress, + object flagValue, + string responseAddress, + object responseValue, + CancellationToken ct = default); + + /// + /// Subscribes to value changes for specified addresses. + /// + /// The tag addresses to monitor. + /// Callback for value changes. + /// Cancellation token. + /// Subscription handle for unsubscribing. + Task SubscribeAsync(IEnumerable addresses, Action callback, + CancellationToken ct = default); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs new file mode 100644 index 0000000..8cd7715 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs @@ -0,0 +1,124 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// 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. + /// + public enum Quality : byte + { + // ─────────────── Bad family (0-31) ─────────────── + /// 0x00 – Bad [Non-Specific] + Bad = 0, + + /// 0x01 – Unknown quality value + Unknown = 1, + + /// 0x04 – Bad [Configuration Error] + Bad_ConfigError = 4, + + /// 0x08 – Bad [Not Connected] + Bad_NotConnected = 8, + + /// 0x0C – Bad [Device Failure] + Bad_DeviceFailure = 12, + + /// 0x10 – Bad [Sensor Failure] + Bad_SensorFailure = 16, + + /// 0x14 – Bad [Last Known Value] + Bad_LastKnownValue = 20, + + /// 0x18 – Bad [Communication Failure] + Bad_CommFailure = 24, + + /// 0x1C – Bad [Out of Service] + Bad_OutOfService = 28, + + // ──────────── Uncertain family (64-95) ─────────── + /// 0x40 – Uncertain [Non-Specific] + Uncertain = 64, + + /// 0x41 – Uncertain [Non-Specific] (Low Limited) + Uncertain_LowLimited = 65, + + /// 0x42 – Uncertain [Non-Specific] (High Limited) + Uncertain_HighLimited = 66, + + /// 0x43 – Uncertain [Non-Specific] (Constant) + Uncertain_Constant = 67, + + /// 0x44 – Uncertain [Last Usable] + Uncertain_LastUsable = 68, + + /// 0x45 – Uncertain [Last Usable] (Low Limited) + Uncertain_LastUsable_LL = 69, + + /// 0x46 – Uncertain [Last Usable] (High Limited) + Uncertain_LastUsable_HL = 70, + + /// 0x47 – Uncertain [Last Usable] (Constant) + Uncertain_LastUsable_Cnst = 71, + + /// 0x50 – Uncertain [Sensor Not Accurate] + Uncertain_SensorNotAcc = 80, + + /// 0x51 – Uncertain [Sensor Not Accurate] (Low Limited) + Uncertain_SensorNotAcc_LL = 81, + + /// 0x52 – Uncertain [Sensor Not Accurate] (High Limited) + Uncertain_SensorNotAcc_HL = 82, + + /// 0x53 – Uncertain [Sensor Not Accurate] (Constant) + Uncertain_SensorNotAcc_C = 83, + + /// 0x54 – Uncertain [EU Exceeded] + Uncertain_EuExceeded = 84, + + /// 0x55 – Uncertain [EU Exceeded] (Low Limited) + Uncertain_EuExceeded_LL = 85, + + /// 0x56 – Uncertain [EU Exceeded] (High Limited) + Uncertain_EuExceeded_HL = 86, + + /// 0x57 – Uncertain [EU Exceeded] (Constant) + Uncertain_EuExceeded_C = 87, + + /// 0x58 – Uncertain [Sub-Normal] + Uncertain_SubNormal = 88, + + /// 0x59 – Uncertain [Sub-Normal] (Low Limited) + Uncertain_SubNormal_LL = 89, + + /// 0x5A – Uncertain [Sub-Normal] (High Limited) + Uncertain_SubNormal_HL = 90, + + /// 0x5B – Uncertain [Sub-Normal] (Constant) + Uncertain_SubNormal_C = 91, + + // ─────────────── Good family (192-219) ──────────── + /// 0xC0 – Good [Non-Specific] + Good = 192, + + /// 0xC1 – Good [Non-Specific] (Low Limited) + Good_LowLimited = 193, + + /// 0xC2 – Good [Non-Specific] (High Limited) + Good_HighLimited = 194, + + /// 0xC3 – Good [Non-Specific] (Constant) + Good_Constant = 195, + + /// 0xD8 – Good [Local Override] + Good_LocalOverride = 216, + + /// 0xD9 – Good [Local Override] (Low Limited) + Good_LocalOverride_LL = 217, + + /// 0xDA – Good [Local Override] (High Limited) + Good_LocalOverride_HL = 218, + + /// 0xDB – Good [Local Override] (Constant) + Good_LocalOverride_C = 219 + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs new file mode 100644 index 0000000..bea1675 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Subscription statistics for all clients and tags. + /// + public class SubscriptionStats + { + /// + /// Gets or sets the total number of clients. + /// + public int TotalClients { get; set; } + + /// + /// Gets or sets the total number of tags. + /// + public int TotalTags { get; set; } + + /// + /// Gets or sets the mapping of tag addresses to client counts. + /// + public Dictionary TagClientCounts { get; set; } = new(); + + /// + /// Gets or sets the mapping of client IDs to their statistics. + /// + public Dictionary ClientStats { get; set; } = new(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs new file mode 100644 index 0000000..7249908 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs @@ -0,0 +1,129 @@ +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Value, Timestamp, and Quality structure for SCADA data. + /// + public readonly struct Vtq : IEquatable + { + /// + /// Gets the value. + /// + public object? Value { get; } + + /// + /// Gets the timestamp when the value was read. + /// + public DateTime Timestamp { get; } + + /// + /// Gets the quality of the value. + /// + public Quality Quality { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The value. + /// The timestamp when the value was read. + /// The quality of the value. + public Vtq(object? value, DateTime timestamp, Quality quality) + { + Value = value; + Timestamp = timestamp; + Quality = quality; + } + + /// + /// Creates a new instance with the specified value and quality, using the current UTC timestamp. + /// + /// The value. + /// The quality of the value. + /// A new instance. + public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality); + + /// + /// Creates a new instance with the specified value, timestamp, and quality. + /// + /// The value. + /// The timestamp when the value was read. + /// The quality of the value. + /// A new instance. + public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); + + /// + /// Creates a instance with good quality and the current UTC timestamp. + /// + /// The value. + /// A new instance with good quality. + public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); + + /// + /// Creates a instance with bad quality and the current UTC timestamp. + /// + /// The value. Optional. + /// A new instance with bad quality. + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + + /// + /// Creates a instance with uncertain quality and the current UTC timestamp. + /// + /// The value. + /// A new instance with uncertain quality. + public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// true if the specified is equal to the current ; otherwise, false. + public bool Equals(Vtq other) => + Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current . + /// true if the specified object is equal to the current ; otherwise, false. + public override bool Equals(object obj) => obj is Vtq other && Equals(other); + + /// + /// Returns the hash code for this instance. + /// + /// A 32-bit signed integer hash code. + public override int GetHashCode() + { + unchecked + { + int hashCode = Value != null ? Value.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)Quality; + return hashCode; + } + } + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current object. + public override string ToString() => + $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; + + /// + /// Determines whether two specified instances of are equal. + /// + /// The first to compare. + /// The second to compare. + /// true if left and right are equal; otherwise, false. + public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal. + /// + /// The first to compare. + /// The second to compare. + /// true if left and right are not equal; otherwise, false. + public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto new file mode 100644 index 0000000..145b684 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto @@ -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; +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs new file mode 100644 index 0000000..8987251 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs @@ -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 +{ + /// + /// gRPC service implementation for SCADA operations. + /// Provides methods for connecting, reading, writing, batch operations, and subscriptions. + /// + public class ScadaGrpcService : ScadaService.ScadaServiceBase + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly PerformanceMetrics _performanceMetrics; + private readonly IScadaClient _scadaClient; + private readonly SessionManager _sessionManager; + private readonly SubscriptionManager _subscriptionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The SCADA client instance. + /// The subscription manager instance. + /// The session manager instance. + /// Optional performance metrics service for tracking operations. + /// Thrown if any required argument is null. + 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 + + /// + /// Creates a new session for a client. + /// The MxAccess connection is managed separately at server startup. + /// + /// The connection request with client ID and API key. + /// The gRPC server call context. + /// A with session ID. + public override Task 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 + }); + } + } + + /// + /// Terminates a client session. + /// + /// The disconnect request with session ID. + /// The gRPC server call context. + /// A indicating success or failure. + public override Task 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 + }); + } + } + + /// + /// Gets the connection state for a session. + /// + /// The connection state request with session ID. + /// The gRPC server call context. + /// A with connection details. + public override Task 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 + + /// + /// Reads a single tag value from the SCADA system. + /// + /// The read request with session ID and tag. + /// The gRPC server call context. + /// A with the VTQ data. + public override async Task 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) + }; + } + } + } + + /// + /// Reads multiple tag values from the SCADA system. + /// + /// The batch read request with session ID and tags. + /// The gRPC server call context. + /// A with VTQ data for each tag. + public override async Task 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 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 + + /// + /// Writes a single tag value to the SCADA system. + /// + /// The write request with session ID, tag, and value. + /// The gRPC server call context. + /// A indicating success or failure. + public override async Task 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 + }; + } + } + } + + /// + /// Writes multiple tag values to the SCADA system. + /// + /// The batch write request with session ID and items. + /// The gRPC server call context. + /// A with results for each tag. + public override async Task 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(); + 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; + } + } + } + + /// + /// Writes a batch of tag values and waits for a flag tag to reach a specific value. + /// + /// The batch write and wait request. + /// The gRPC server call context. + /// A with results and flag status. + public override async Task 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(); + 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 + + /// + /// Subscribes to value changes for specified tags and streams updates to the client. + /// + /// The subscribe request with session ID and tags. + /// The server stream writer for VTQ updates. + /// The gRPC server call context. + /// A task representing the asynchronous operation. + public override async Task Subscribe(SubscribeRequest request, + IServerStreamWriter 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 + + /// + /// Checks the validity of an API key. + /// + /// The API key check request. + /// The gRPC server call context. + /// A with validity and details. + public override Task 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 + + /// + /// Converts a domain to a gRPC . + /// + 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) + }; + } + + /// + /// Creates a bad quality VTQ message for error cases. + /// + private static VtqMessage CreateBadVtqMessage(string tag) + { + return new VtqMessage + { + Tag = tag, + Value = string.Empty, + TimestampUtcTicks = DateTime.UtcNow.Ticks, + Quality = "Bad" + }; + } + + /// + /// Converts a value to its string representation. + /// + 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 + }; + } + + /// + /// Converts a domain quality value to a string. + /// + 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 + } + + /// + /// Parses a string value to an appropriate .NET type. + /// + 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; + } + + /// + /// Compares two values for equality. + /// + 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 + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs new file mode 100644 index 0000000..6e1179a --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs @@ -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 +{ + /// + /// Connection management for MxAccessClient. + /// + public sealed partial class MxAccessClient + { + /// + /// Asynchronously connects to the MxAccess server. + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous connect operation. + /// Thrown if the client has been disposed. + /// Thrown if registration with MxAccess fails. + /// Thrown if any other error occurs during connection. + 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(); + } + + /// + /// Asynchronously disconnects from the MxAccess server and cleans up resources. + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous disconnect operation. + 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); + } + + /// + /// Internal synchronous connection logic. + /// + 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; + } + } + } + + /// + /// Validates that the client has not been disposed. + /// + private void ValidateNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MxAccessClient)); + } + } + + /// + /// Initializes the MxAccess COM connection and event handlers. + /// + private void InitializeMxAccessConnection() + { + // Create the COM object + _lmxProxy = new LMXProxyServer(); + + // Wire up event handlers + _lmxProxy.OnDataChange += OnDataChange; + _lmxProxy.OnWriteComplete += OnWriteComplete; + _lmxProxy.OperationComplete += OnOperationComplete; + } + + /// + /// Registers with the MxAccess server. + /// + 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"); + } + } + + /// + /// Internal synchronous disconnection logic. + /// + 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); + } + } + } + + /// + /// Removes all active subscriptions. + /// + private void RemoveAllSubscriptions() + { + var subscriptionsToRemove = _subscriptions.Values.ToList(); + var failedRemovals = new List(); + + 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 + } + + /// + /// Attempts to remove a single subscription. + /// + 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; + } + } + + /// + /// Unregisters from the MxAccess server. + /// + private void UnregisterFromMxAccess() + { + if (_connectionHandle > 0 && _lmxProxy != null) + { + _lmxProxy.Unregister(_connectionHandle); + _connectionHandle = 0; + } + } + + /// + /// Cleans up resources and releases the COM object. + /// Removes event handlers and releases the proxy COM object if present. + /// + 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"); + } + } + + /// + /// Recreates all stored subscriptions after reconnection. + /// + private async Task RecreateStoredSubscriptionsAsync() + { + List subscriptionsToRecreate; + + lock (_lock) + { + // Create a copy to avoid holding the lock during async operations + subscriptionsToRecreate = new List(_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); + } + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs new file mode 100644 index 0000000..8735737 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs @@ -0,0 +1,166 @@ +using System; +using ArchestrA.MxAccess; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Implementation +{ + /// + /// Event handlers for MxAccessClient to process data changes, write completions, and operation completions. + /// + public sealed partial class MxAccessClient + { + /// + /// Handles data change events from the MxAccess server. + /// + /// Server handle. + /// Item handle. + /// Item value. + /// Item quality code. + /// Item timestamp. + /// Status array. + 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); + } + } + + /// + /// Handles write completion events from the MxAccess server. + /// + /// Server handle. + /// Item handle. + /// Status array. + 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); + } + } + + /// + /// Handles operation completion events from the MxAccess server. + /// + /// Server handle. + /// Item handle. + /// Status array. + private void OnOperationComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus) + { + // Log operation completion + Logger.Debug("Operation complete for handle {Handle}", phItemHandle); + } + + /// + /// Converts an integer MxAccess quality code to . + /// + /// The MxAccess quality code. + /// The corresponding value. + private Quality ConvertQuality(int mxQuality) => (Quality)mxQuality; + + /// + /// Converts a timestamp object to in UTC. + /// + /// The timestamp object. + /// The UTC value. + private DateTime ConvertTimestamp(object timestamp) + { + if (timestamp is DateTime dt) + { + return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); + } + + return DateTime.UtcNow; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs new file mode 100644 index 0000000..d501d82 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs @@ -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 +{ + /// + /// Private nested types for MxAccessClient to encapsulate subscription and write operation details. + /// + public sealed partial class MxAccessClient + { + /// + /// Holds information about a subscription to a SCADA tag. + /// + private class SubscriptionInfo + { + /// + /// Gets or sets the address of the tag. + /// + public string Address { get; set; } = string.Empty; + + /// + /// Gets or sets the item handle. + /// + public int ItemHandle { get; set; } + + /// + /// Gets or sets the callback for value changes. + /// + public Action? Callback { get; set; } + + /// + /// Gets or sets the subscription identifier. + /// + public string SubscriptionId { get; set; } = string.Empty; + } + + /// + /// Represents a handle for a subscription, allowing asynchronous disposal. + /// + private class SubscriptionHandle : IAsyncDisposable + { + private readonly MxAccessClient _client; + private readonly string _groupId; + private readonly List _subscriptionIds; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The subscription identifiers. + /// The group identifier for stored subscriptions. + public SubscriptionHandle(MxAccessClient client, List subscriptionIds, string groupId) + { + _client = client; + _subscriptionIds = subscriptionIds; + _groupId = groupId; + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + var tasks = new List(); + foreach (string? id in _subscriptionIds) + { + tasks.Add(_client.UnsubscribeInternalAsync(id)); + } + + await Task.WhenAll(tasks); + + // Remove the stored subscription group + _client.RemoveStoredSubscription(_groupId); + } + } + + /// + /// Represents a pending write operation. + /// + private class WriteOperation + { + /// + /// Gets or sets the address of the tag. + /// + public string Address { get; set; } = string.Empty; + + /// + /// Gets or sets the item handle. + /// + public int ItemHandle { get; set; } + + /// + /// Gets or sets the completion source for the write operation. + /// + public TaskCompletionSource CompletionSource { get; set; } = null!; + + /// + /// Gets or sets the start time of the write operation. + /// + public DateTime StartTime { get; set; } + } + + /// + /// Stores subscription information for automatic recreation after reconnection. + /// + private class StoredSubscription + { + /// + /// Gets or sets the addresses that were subscribed to. + /// + public List Addresses { get; set; } = new(); + + /// + /// Gets or sets the callback for value changes. + /// + public Action Callback { get; set; } = null!; + + /// + /// Gets or sets the unique identifier for this stored subscription group. + /// + public string GroupId { get; set; } = string.Empty; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs new file mode 100644 index 0000000..11f836d --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs @@ -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 +{ + /// + /// Read and write operations for MxAccessClient. + /// + public sealed partial class MxAccessClient + { + /// + public async Task ReadAsync(string address, CancellationToken ct = default) + { + // Apply retry policy for read operations + IAsyncPolicy policy = RetryPolicies.CreateReadPolicy(); + return await policy.ExecuteWithRetryAsync(async () => + { + ValidateConnection(); + return await ReadSingleValueAsync(address, ct); + }, $"Read-{address}"); + } + + /// + public async Task> ReadBatchAsync(IEnumerable addresses, + CancellationToken ct = default) + { + var addressList = addresses.ToList(); + var results = new Dictionary(addressList.Count); + + // Create tasks for parallel reading + IEnumerable tasks = + addressList.Select(address => ReadAddressWithSemaphoreAsync(address, results, ct)); + + await Task.WhenAll(tasks); + return results; + } + + /// + 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}"); + } + + /// + public async Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) + { + // Create tasks for parallel writing + IEnumerable tasks = values.Select(kvp => WriteAddressWithSemaphoreAsync(kvp.Key, kvp.Value, ct)); + + await Task.WhenAll(tasks); + } + + /// + public async Task WriteBatchAndWaitAsync( + IReadOnlyDictionary 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 + + /// + /// Validates that the client is connected. + /// + private void ValidateConnection() + { + if (!IsConnected) + { + throw new InvalidOperationException("Not connected to MxAccess"); + } + } + + /// + /// Reads a single value from the specified address. + /// + private async Task ReadSingleValueAsync(string address, CancellationToken ct) + { + // MxAccess doesn't support direct read - we need to subscribe, get the value, then unsubscribe + var tcs = new TaskCompletionSource(); + 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(); + } + } + } + + /// + /// Waits for a read result with timeout. + /// + private async Task WaitForReadResultAsync(TaskCompletionSource 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; + } + } + } + + /// + /// Reads an address with semaphore protection for batch operations. + /// + private async Task ReadAddressWithSemaphoreAsync(string address, Dictionary 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(); + } + } + + /// + /// Internal write implementation. + /// + private async Task WriteInternalAsync(string address, object value, CancellationToken ct) + { + var tcs = new TaskCompletionSource(); + int itemHandle = await SetupWriteOperationAsync(address, value, tcs, ct); + + try + { + await WaitForWriteCompletionAsync(tcs, itemHandle, address, ct); + } + catch + { + await CleanupWriteOperationAsync(itemHandle); + throw; + } + } + + /// + /// Sets up a write operation and returns the item handle. + /// + private async Task SetupWriteOperationAsync(string address, object value, TaskCompletionSource tcs, + CancellationToken ct) + { + return await Task.Run(() => + { + lock (_lock) + { + ValidateConnectionLocked(); + return InitiateWriteOperation(address, value, tcs); + } + }, ct); + } + + /// + /// Validates connection while holding the lock. + /// + private void ValidateConnectionLocked() + { + if (!IsConnected || _lmxProxy == null) + { + throw new InvalidOperationException("Not connected to MxAccess"); + } + } + + /// + /// Initiates a write operation and returns the item handle. + /// + private int InitiateWriteOperation(string address, object value, TaskCompletionSource 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; + } + } + + /// + /// Tracks a pending write operation. + /// + private void TrackPendingWrite(string address, int itemHandle, TaskCompletionSource tcs) + { + var writeOp = new WriteOperation + { + Address = address, + ItemHandle = itemHandle, + CompletionSource = tcs, + StartTime = DateTime.UtcNow + }; + _pendingWrites[itemHandle] = writeOp; + } + + /// + /// Cleans up a failed write operation. + /// + private void CleanupFailedWrite(int itemHandle) + { + if (itemHandle > 0 && _lmxProxy != null) + { + try + { + _lmxProxy.UnAdvise(_connectionHandle, itemHandle); + _lmxProxy.RemoveItem(_connectionHandle, itemHandle); + _pendingWrites.Remove(itemHandle); + } + catch + { + } + } + } + + /// + /// Waits for write completion with timeout. + /// + private async Task WaitForWriteCompletionAsync(TaskCompletionSource 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 + } + } + + /// + /// Handles write timeout by cleaning up resources. + /// + private async Task HandleWriteTimeoutAsync(int itemHandle, string address) + { + await CleanupWriteOperationAsync(itemHandle); + throw new TimeoutException($"Write operation to {address} timed out"); + } + + /// + /// Cleans up a write operation. + /// + 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 + { + } + } + } + } + }); + } + + /// + /// Writes an address with semaphore protection for batch operations. + /// + private async Task WriteAddressWithSemaphoreAsync(string address, object value, CancellationToken ct) + { + await _writeSemaphore.WaitAsync(ct); + try + { + await WriteAsync(address, value, ct); + } + finally + { + _writeSemaphore.Release(); + } + } + + /// + /// Waits for a specific response value. + /// + private async Task WaitForResponseAsync(string responseAddress, object responseValue, + CancellationToken ct) + { + var tcs = new TaskCompletionSource(); + 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(); + } + } + } + + /// + /// Gets a human-readable error message for a write error code. + /// + /// The error code. + /// The error message. + 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 + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs new file mode 100644 index 0000000..88c4c12 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs @@ -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 +{ + /// + /// Subscription management for MxAccessClient to handle SCADA tag updates. + /// + public sealed partial class MxAccessClient + { + /// + /// Subscribes to a set of addresses and registers a callback for value changes. + /// + /// The collection of addresses to subscribe to. + /// + /// The callback to invoke when a value changes. + /// The callback receives the address and the new value. + /// + /// An optional to cancel the operation. + /// + /// A that completes with a handle to the subscription. + /// Disposing the handle will unsubscribe from all addresses. + /// + /// Thrown if not connected to MxAccess. + /// Thrown if subscription fails for any address. + public Task SubscribeAsync(IEnumerable addresses, Action callback, + CancellationToken ct = default) => SubscribeInternalAsync(addresses, callback, true, ct); + + /// + /// Internal subscription method that allows control over whether to store the subscription for recreation. + /// + private Task SubscribeInternalAsync(IEnumerable addresses, + Action callback, bool storeForRecreation, CancellationToken ct = default) + { + return Task.Run(() => + { + lock (_lock) + { + if (!IsConnected || _lmxProxy == null) + { + throw new InvalidOperationException("Not connected to MxAccess"); + } + + var subscriptionIds = new List(); + + 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); + } + + /// + /// Unsubscribes from a subscription by its ID. + /// + /// The subscription identifier. + /// + /// A representing the asynchronous operation. + /// + 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); + } + } + }); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs new file mode 100644 index 0000000..1db4225 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs @@ -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 +{ + /// + /// Implementation of using ArchestrA MxAccess. + /// Provides connection management, read/write operations, and subscription support for SCADA tags. + /// + public sealed partial class MxAccessClient : IScadaClient + { + private const int DefaultMaxConcurrency = 10; + private static readonly ILogger Logger = Log.ForContext(); + private readonly ConnectionConfiguration _configuration; + + private readonly object _lock = new(); + private readonly Dictionary _pendingWrites = new(); + + // Concurrency control for batch operations + private readonly SemaphoreSlim _readSemaphore; + + // Store subscription details for automatic recreation after reconnect + private readonly List _storedSubscriptions = new(); + private readonly Dictionary _subscriptions = new(); + private readonly Dictionary _subscriptionsByHandle = new(); + private readonly SemaphoreSlim _writeSemaphore; + private int _connectionHandle; + private ConnectionState _connectionState = ConnectionState.Disconnected; + private bool _disposed; + private LMXProxyServer? _lmxProxy; + + /// + /// Initializes a new instance of the class. + /// + /// The connection configuration settings. + 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); + } + + /// + public bool IsConnected + { + get + { + lock (_lock) + { + return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0; + } + } + } + + /// + public ConnectionState ConnectionState + { + get + { + lock (_lock) + { + return _connectionState; + } + } + } + + /// + /// Occurs when the connection state changes. + /// + public event EventHandler? ConnectionStateChanged; + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + await DisconnectAsync(); + _disposed = true; + + // Dispose semaphores + _readSemaphore?.Dispose(); + _writeSemaphore?.Dispose(); + } + + /// + public void Dispose() => DisposeAsync().GetAwaiter().GetResult(); + + /// + /// Sets the connection state and raises the event. + /// + /// The new connection state. + /// Optional message describing the state change. + 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)); + } + + /// + /// Removes a stored subscription group by its ID. + /// + /// The group identifier to remove. + 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 + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs new file mode 100644 index 0000000..403abee --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs @@ -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 +{ + /// + /// Windows service that hosts the gRPC server and MxAccess client. + /// Manages lifecycle of gRPC server, SCADA client, subscription manager, and API key service. + /// + public class LmxProxyService + { + private static readonly ILogger Logger = Log.ForContext(); + private readonly LmxProxyConfiguration _configuration; + private readonly SemaphoreSlim _reconnectSemaphore = new(1, 1); + private readonly Func _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; + + /// + /// Initializes a new instance of the class. + /// + /// Configuration settings for the service. + /// Thrown if configuration is null. + public LmxProxyService(LmxProxyConfiguration configuration, + Func? scadaClientFactory = null) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _scadaClientFactory = scadaClientFactory ?? (config => new MxAccessClient(config.Connection)); + } + + /// + /// Starts the LmxProxy service, initializing all required components and starting the gRPC server. + /// + /// true if the service started successfully; otherwise, false. + 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; + } + } + + /// + /// Stops the LmxProxy service, shutting down the gRPC server and disposing all resources. + /// + /// true if the service stopped successfully; otherwise, false. + 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; + } + } + + /// + /// Pauses the LmxProxy service. No operation is performed except logging. + /// + public void Pause() => Logger.Information("LmxProxy service paused"); + + /// + /// Continues the LmxProxy service after a pause. No operation is performed except logging. + /// + public void Continue() => Logger.Information("LmxProxy service continued"); + + /// + /// Requests shutdown of the LmxProxy service and stops all components. + /// + public void Shutdown() + { + Logger.Information("LmxProxy service shutdown requested"); + Stop(); + } + + /// + /// Handles connection state changes from the SCADA client. + /// + 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."); + } + } + + /// + /// Monitors the connection and attempts to reconnect when disconnected. + /// + 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"); + } + + /// + /// Creates TLS server credentials from configuration + /// + 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 + { + 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); + } + } + + /// + /// Validates the service configuration and returns false if any critical issues are found + /// + 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; + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs new file mode 100644 index 0000000..c057a30 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs @@ -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(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(); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs new file mode 100644 index 0000000..9472a8c --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs @@ -0,0 +1,49 @@ +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// + /// Represents an API key with associated permissions + /// + public class ApiKey + { + /// + /// The API key value + /// + public string Key { get; set; } = string.Empty; + + /// + /// Description of what this API key is used for + /// + public string Description { get; set; } = string.Empty; + + /// + /// The role assigned to this API key + /// + public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly; + + /// + /// Whether this API key is enabled + /// + public bool Enabled { get; set; } = true; + + /// + /// Checks if the API key is valid + /// + public bool IsValid() => Enabled; + } + + /// + /// API key roles + /// + public enum ApiKeyRole + { + /// + /// Can only read data + /// + ReadOnly, + + /// + /// Can read and write data + /// + ReadWrite + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs new file mode 100644 index 0000000..02284be --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// + /// Configuration for API keys loaded from file + /// + public class ApiKeyConfiguration + { + /// + /// List of API keys + /// + public List ApiKeys { get; set; } = new(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs new file mode 100644 index 0000000..4a1a87d --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs @@ -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 +{ + /// + /// gRPC interceptor for API key authentication. + /// Validates API keys for incoming requests and enforces role-based access control. + /// + public class ApiKeyInterceptor : Interceptor + { + private static readonly ILogger Logger = Log.ForContext(); + + /// + /// List of gRPC method names that require write access. + /// + private static readonly string[] WriteMethodNames = + { + "Write", + "WriteBatch", + "WriteBatchAndWait" + }; + + private readonly ApiKeyService _apiKeyService; + + /// + /// Initializes a new instance of the class. + /// + /// The API key service used for validation. + /// Thrown if is null. + public ApiKeyInterceptor(ApiKeyService apiKeyService) + { + _apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService)); + } + + /// + /// Handles unary gRPC calls, validating API key and enforcing permissions. + /// + /// The request type. + /// The response type. + /// The request message. + /// The server call context. + /// The continuation delegate. + /// The response message. + /// Thrown if authentication or authorization fails. + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod 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); + } + + /// + /// Handles server streaming gRPC calls, validating API key and enforcing permissions. + /// + /// The request type. + /// The response type. + /// The request message. + /// The response stream writer. + /// The server call context. + /// The continuation delegate. + /// A task representing the asynchronous operation. + /// Thrown if authentication fails. + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod 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); + } + + /// + /// Extracts the API key from the gRPC request headers. + /// + /// The server call context. + /// The API key value, or an empty string if not found. + 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; + } + + /// + /// Gets the method name from the full gRPC method string. + /// + /// The full method string (e.g., /package.Service/Method). + /// The method name. + private static string GetMethodName(string method) + { + // Method format is /package.Service/Method + int lastSlash = method.LastIndexOf('/'); + return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method; + } + + /// + /// Determines whether the specified method name requires write access. + /// + /// The method name. + /// true if the method requires write access; otherwise, false. + private static bool IsWriteMethod(string methodName) => + WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs new file mode 100644 index 0000000..bf6f8f5 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs @@ -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 +{ + /// + /// Service for managing API keys with file-based storage. + /// Handles validation, role checking, and automatic reload on file changes. + /// + public class ApiKeyService : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + private readonly ConcurrentDictionary _apiKeys; + private readonly string _configFilePath; + private readonly SemaphoreSlim _reloadLock = new(1, 1); + private bool _disposed; + private FileSystemWatcher? _fileWatcher; + private DateTime _lastReloadTime = DateTime.MinValue; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the API key configuration file. + /// Thrown if is null. + public ApiKeyService(string configFilePath) + { + _configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath)); + _apiKeys = new ConcurrentDictionary(); + + InitializeFileWatcher(); + LoadConfiguration(); + } + + /// + /// Disposes the and releases resources. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _fileWatcher?.Dispose(); + _reloadLock?.Dispose(); + + Logger.Information("API key service disposed"); + } + + /// + /// Validates an API key and returns its details if valid. + /// + /// The API key value to validate. + /// The if valid; otherwise, null. + 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; + } + + /// + /// Checks if an API key has the specified role. + /// + /// The API key value. + /// The required . + /// true if the API key has the required role; otherwise, false. + 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; + } + + /// + /// Initializes the file system watcher for the API key configuration file. + /// + 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); + } + } + + /// + /// Handles file change events for the configuration file. + /// + /// The event sender. + /// The instance containing event data. + 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()); + } + } + + /// + /// Handles file rename events for the configuration file. + /// + /// The event sender. + /// The instance containing event data. + 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()); + } + } + + /// + /// Asynchronously reloads the API key configuration from file. + /// Debounces rapid file changes to avoid excessive reloads. + /// + 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(); + } + } + + /// + /// Loads the API key configuration from file. + /// If the file does not exist, creates a default configuration. + /// + 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(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); + } + } + + /// + /// Creates a default API key configuration file with sample keys. + /// + private void CreateDefaultConfiguration() + { + try + { + var defaultConfig = new ApiKeyConfiguration + { + ApiKeys = new List + { + 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"); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs new file mode 100644 index 0000000..6317607 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs @@ -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 +{ + /// + /// Manages TLS certificates for the LmxProxy service, including generation and validation + /// + public class TlsCertificateManager + { + private static readonly ILogger Logger = Log.ForContext(); + private readonly TlsConfiguration _tlsConfiguration; + + public TlsCertificateManager(TlsConfiguration tlsConfiguration) + { + _tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration)); + } + + /// + /// Checks TLS certificate status and creates new certificates if needed + /// + /// True if certificates are valid or were successfully created + 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; + } + } + + /// + /// Checks if a certificate is expiring within the next year + /// + 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; + } + } + + /// + /// Generates a new self-signed certificate + /// + 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; + } + } + + /// + /// Exports a certificate to PEM format + /// + 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(); + } + + /// + /// Exports an RSA private key to PEM format + /// + 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(); + } + + /// + /// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8 + /// + 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)); + } + } + + /// + /// Extracts bytes from PEM format + /// + 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); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs new file mode 100644 index 0000000..8fbdaf0 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs @@ -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 +{ + /// + /// Health check service for monitoring LmxProxy health + /// + public class HealthCheckService : IHealthCheck + { + private static readonly ILogger Logger = Log.ForContext(); + 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 CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var data = new Dictionary(); + + 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 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)); + } + } + } + + /// + /// Detailed health check that performs additional connectivity tests + /// + public class DetailedHealthCheckService : IHealthCheck + { + private static readonly ILogger Logger = Log.ForContext(); + + 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 CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var data = new Dictionary(); + + 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); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs new file mode 100644 index 0000000..c0c2525 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs @@ -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 +{ + /// + /// Provides performance metrics tracking for LmxProxy operations + /// + public class PerformanceMetrics : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly ConcurrentDictionary _metrics = new(); + private readonly Timer _reportingTimer; + private bool _disposed; + + /// + /// Initializes a new instance of the PerformanceMetrics class + /// + 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 + } + + /// + /// Records the execution time of an operation + /// + public void RecordOperation(string operationName, TimeSpan duration, bool success = true) + { + OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics()); + metrics.Record(duration, success); + } + + /// + /// Creates a timing scope for measuring operation duration + /// + public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName); + + /// + /// Gets current metrics for a specific operation + /// + public OperationMetrics? GetMetrics(string operationName) => + _metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null; + + /// + /// Gets all current metrics + /// + public IReadOnlyDictionary GetAllMetrics() => + _metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + /// + /// Gets statistics for all operations + /// + public Dictionary GetStatistics() => + _metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics()); + + private void ReportMetrics(object? state) + { + foreach (KeyValuePair 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); + } + } + } + + /// + /// Timing scope for automatic duration measurement + /// + 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); + } + } + } + + /// + /// Metrics for a specific operation + /// + public class OperationMetrics + { + private readonly List _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 + }; + } + } + } + + /// + /// Statistics for an operation + /// + 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; } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs new file mode 100644 index 0000000..0c08723 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs @@ -0,0 +1,193 @@ +using System; +using System.Threading.Tasks; +using Polly; +using Polly.Timeout; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Services +{ + /// + /// Provides retry policies for resilient operations + /// + public static class RetryPolicies + { + private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies)); + + /// + /// Creates a retry policy with exponential backoff for read operations + /// + public static IAsyncPolicy CreateReadPolicy() + { + return Policy + .Handle(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"); + }); + } + + /// + /// Creates a retry policy with exponential backoff for write operations + /// + public static IAsyncPolicy CreateWritePolicy() + { + return Policy + .Handle(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"); + }); + } + + /// + /// Creates a retry policy for connection operations with longer delays + /// + public static IAsyncPolicy CreateConnectionPolicy() + { + return Policy + .Handle() + .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); + }); + } + + /// + /// Creates a circuit breaker policy for protecting against repeated failures + /// + public static IAsyncPolicy CreateCircuitBreakerPolicy() + { + return Policy + .Handle() + .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"); }); + } + + /// + /// Creates a combined policy with retry and circuit breaker + /// + public static IAsyncPolicy CreateCombinedPolicy() + { + IAsyncPolicy retry = CreateReadPolicy(); + IAsyncPolicy circuitBreaker = CreateCircuitBreakerPolicy(); + + // 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); + } + + /// + /// Creates a timeout policy for operations + /// + 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 + } + } + }); + } + + /// + /// Creates a bulkhead policy to limit concurrent operations + /// + 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; + }); + } + } + + /// + /// Extension methods for applying retry policies + /// + public static class RetryPolicyExtensions + { + /// + /// Executes an operation with retry policy + /// + public static async Task ExecuteWithRetryAsync( + this IAsyncPolicy policy, + Func> operation, + string operationName) + { + var context = new Context { ["Operation"] = operationName }; + return await policy.ExecuteAsync(async ctx => await operation(), context); + } + + /// + /// Executes an operation with retry policy (non-generic) + /// + public static async Task ExecuteWithRetryAsync( + this IAsyncPolicy policy, + Func operation, + string operationName) + { + var context = new Context { ["Operation"] = operationName }; + await policy.ExecuteAsync(async ctx => await operation(), context); + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs new file mode 100644 index 0000000..f91fce1 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs @@ -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 +{ + /// + /// Manages client sessions for the gRPC service. + /// Tracks active sessions with unique session IDs. + /// + public class SessionManager : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly ConcurrentDictionary _sessions = new(); + private bool _disposed; + + /// + /// Gets the number of active sessions. + /// + public int ActiveSessionCount => _sessions.Count; + + /// + /// Creates a new session for a client. + /// + /// The client identifier. + /// The API key used for authentication (optional). + /// The session ID for the new session. + /// Thrown if the manager is disposed. + 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; + } + + /// + /// Validates a session ID and updates the last activity timestamp. + /// + /// The session ID to validate. + /// True if the session is valid; otherwise, false. + 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; + } + + /// + /// Gets the session information for a session ID. + /// + /// The session ID. + /// The session information, or null if not found. + public SessionInfo GetSession(string sessionId) + { + if (_disposed || string.IsNullOrEmpty(sessionId)) + { + return null; + } + + _sessions.TryGetValue(sessionId, out SessionInfo sessionInfo); + return sessionInfo; + } + + /// + /// Terminates a session. + /// + /// The session ID to terminate. + /// True if the session was terminated; otherwise, false. + 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; + } + + /// + /// Gets all active sessions. + /// + /// A list of all active session information. + public IReadOnlyList GetAllSessions() + { + return _sessions.Values.ToList(); + } + + /// + /// Disposes the session manager and clears all sessions. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + var count = _sessions.Count; + _sessions.Clear(); + + Logger.Information("SessionManager disposed, cleared {Count} sessions", count); + } + } + + /// + /// Contains information about a client session. + /// + public class SessionInfo + { + /// + /// Gets or sets the unique session identifier. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Gets or sets the client identifier. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the API key used for this session. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Gets or sets the time when the session was created. + /// + public DateTime ConnectedAt { get; set; } + + /// + /// Gets or sets the time of the last activity on this session. + /// + public DateTime LastActivity { get; set; } + + /// + /// Gets the connected time as UTC ticks for the gRPC response. + /// + public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs new file mode 100644 index 0000000..95d9042 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs @@ -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 +{ + /// + /// Service for collecting and formatting status information from various LmxProxy components + /// + public class StatusReportService + { + private static readonly ILogger Logger = Log.ForContext(); + private readonly DetailedHealthCheckService? _detailedHealthCheckService; + private readonly HealthCheckService _healthCheckService; + private readonly PerformanceMetrics _performanceMetrics; + + private readonly IScadaClient _scadaClient; + private readonly SubscriptionManager _subscriptionManager; + + /// + /// Initializes a new instance of the StatusReportService class + /// + 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; + } + + /// + /// Generates a comprehensive status report as HTML + /// + public async Task GenerateHtmlReportAsync() + { + try + { + StatusData statusData = await CollectStatusDataAsync(); + return GenerateHtmlFromStatusData(statusData); + } + catch (Exception ex) + { + Logger.Error(ex, "Error generating HTML status report"); + return GenerateErrorHtml(ex); + } + } + + /// + /// Generates a comprehensive status report as JSON + /// + public async Task 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 + }); + } + } + + /// + /// Checks if the service is healthy + /// + public async Task 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; + } + } + + /// + /// Collects status data from all components + /// + private async Task 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 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() + }; + + // 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() + }; + } + } + 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() + }; + } + + return statusData; + } + + /// + /// Generates HTML from status data + /// + private static string GenerateHtmlFromStatusData(StatusData statusData) + { + var html = new StringBuilder(); + + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(" LmxProxy Status"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine("
"); + + // Header + html.AppendLine("
"); + html.AppendLine("

LmxProxy Status Dashboard

"); + html.AppendLine($"

Service: {statusData.ServiceName} | Version: {statusData.Version}

"); + html.AppendLine("
"); + + html.AppendLine("
"); + + // 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($"
"); + html.AppendLine("

MxAccess Connection

"); + html.AppendLine( + $"

Status: {connectionStatusText}

"); + html.AppendLine( + $"

State: {statusData.Connection.State}

"); + html.AppendLine("
"); + + // Subscription Status Card + html.AppendLine("
"); + html.AppendLine("

Subscriptions

"); + html.AppendLine( + $"

Total Clients: {statusData.Subscriptions.TotalClients}

"); + html.AppendLine( + $"

Total Tags: {statusData.Subscriptions.TotalTags}

"); + html.AppendLine( + $"

Active Subscriptions: {statusData.Subscriptions.ActiveSubscriptions}

"); + html.AppendLine("
"); + + // Performance Status Card + html.AppendLine("
"); + html.AppendLine("

Performance

"); + html.AppendLine( + $"

Total Operations: {statusData.Performance.TotalOperations:N0}

"); + html.AppendLine( + $"

Success Rate: {statusData.Performance.AverageSuccessRate:P2}

"); + html.AppendLine("
"); + + // Health Status Card + string healthStatusClass = statusData.Health.Status.ToLowerInvariant() switch + { + "healthy" => "status-healthy", + "degraded" => "status-warning", + _ => "status-error" + }; + + html.AppendLine("
"); + html.AppendLine("

Health Status

"); + html.AppendLine( + $"

Status: {statusData.Health.Status}

"); + html.AppendLine( + $"

Description: {statusData.Health.Description}

"); + html.AppendLine("
"); + + html.AppendLine("
"); + + // Performance Metrics Table + if (statusData.Performance.Operations.Any()) + { + html.AppendLine("
"); + html.AppendLine("

Operation Performance Metrics

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (KeyValuePair operation in statusData.Performance.Operations) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + html.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)
{operation.Key}{operation.Value.TotalCount:N0}{operation.Value.SuccessRate:P2}{operation.Value.AverageMilliseconds:F2}{operation.Value.MinMilliseconds:F2}{operation.Value.MaxMilliseconds:F2}
"); + html.AppendLine("
"); + } + + // Timestamp + html.AppendLine( + $"
Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC
"); + + html.AppendLine("
"); + html.AppendLine(""); + html.AppendLine(""); + + return html.ToString(); + } + + /// + /// Generates error HTML when status collection fails + /// + private static string GenerateErrorHtml(Exception ex) + { + return $@" + + + LmxProxy Status - Error + + + + +
+

LmxProxy Status Dashboard

+
+

Error Loading Status

+

An error occurred while collecting status information:

+

{ex.Message}

+
+
+ Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC +
+
+ +"; + } + } + + /// + /// Data structure for holding complete status information + /// + 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; } + } + + /// + /// Connection status information + /// + public class ConnectionStatus + { + public bool IsConnected { get; set; } + public string State { get; set; } = ""; + public string NodeName { get; set; } = ""; + public string GalaxyName { get; set; } = ""; + } + + /// + /// Subscription status information + /// + public class SubscriptionStatus + { + public int TotalClients { get; set; } + public int TotalTags { get; set; } + public int ActiveSubscriptions { get; set; } + } + + /// + /// Performance status information + /// + public class PerformanceStatus + { + public long TotalOperations { get; set; } + public double AverageSuccessRate { get; set; } + public Dictionary Operations { get; set; } = new(); + } + + /// + /// Individual operation status + /// + 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; } + } + + /// + /// Health check status information + /// + public class HealthInfo + { + public string Status { get; set; } = ""; + public string Description { get; set; } = ""; + public Dictionary Data { get; set; } = new(); + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs new file mode 100644 index 0000000..7c0d41e --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs @@ -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 +{ + /// + /// HTTP web server that serves status information for the LmxProxy service + /// + public class StatusWebServer : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + private readonly WebServerConfiguration _configuration; + private readonly StatusReportService _statusReportService; + private CancellationTokenSource? _cancellationTokenSource; + private bool _disposed; + private HttpListener? _httpListener; + private Task? _listenerTask; + + /// + /// Initializes a new instance of the StatusWebServer class + /// + /// Web server configuration + /// Service for collecting status information + public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService)); + } + + /// + /// Disposes the web server and releases resources + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + Stop(); + + _cancellationTokenSource?.Dispose(); + _httpListener?.Close(); + } + + /// + /// Starts the HTTP web server + /// + /// True if started successfully, false otherwise + 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; + } + } + + /// + /// Stops the HTTP web server + /// + /// True if stopped successfully, false otherwise + 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; + } + } + + /// + /// Main request handling loop + /// + 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"); + } + + /// + /// Handles a single HTTP request + /// + 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"); + } + } + } + + /// + /// Handles the main status page (HTML) + /// + private async Task HandleStatusPageAsync(HttpListenerResponse response) + { + string statusHtml = await _statusReportService.GenerateHtmlReportAsync(); + await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8"); + } + + /// + /// Handles the status API endpoint (JSON) + /// + private async Task HandleStatusApiAsync(HttpListenerResponse response) + { + string statusJson = await _statusReportService.GenerateJsonReportAsync(); + await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8"); + } + + /// + /// Handles the health API endpoint (simple text) + /// + 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"); + } + + /// + /// Writes a response to the HTTP context + /// + 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); + } + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs new file mode 100644 index 0000000..49921eb --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs @@ -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 +{ + /// + /// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client + /// statistics. + /// + public class SubscriptionManager : IDisposable + { + private static readonly ILogger Logger = Log.ForContext(); + + // Configuration for channel buffering + private readonly int _channelCapacity; + private readonly BoundedChannelFullMode _channelFullMode; + private readonly ConcurrentDictionary _clientSubscriptions = new(); + private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion); + + private readonly IScadaClient _scadaClient; + private readonly ConcurrentDictionary _tagSubscriptions = new(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The SCADA client to use for subscriptions. + /// The subscription configuration. + /// + /// Thrown if or + /// is null. + /// + 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); + } + + /// + /// Disposes the , unsubscribing all clients and cleaning up resources. + /// + 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(); + } + + /// + /// Gets the number of active client subscriptions. + /// + public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count; + + /// + /// Parses the channel full mode string to . + /// + /// The mode string. + /// The parsed value. + private static BoundedChannelFullMode ParseChannelFullMode(string mode) + { + return mode?.ToUpperInvariant() switch + { + "DROPOLDEST" => BoundedChannelFullMode.DropOldest, + "DROPNEWEST" => BoundedChannelFullMode.DropNewest, + "WAIT" => BoundedChannelFullMode.Wait, + _ => BoundedChannelFullMode.DropOldest // Default + }; + } + + /// + /// Creates a new subscription for a client to a set of tag addresses. + /// + /// The client identifier. + /// The tag addresses to subscribe to. + /// Optional cancellation token. + /// A channel for receiving tag updates. + /// Thrown if the manager is disposed. + public async Task> SubscribeAsync( + string clientId, + IEnumerable 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(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; + } + + /// + /// Unsubscribes a client from all tags and cleans up resources. + /// + /// The client identifier. + 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(); + } + } + + /// + /// Subscribes a client to a tag address, creating a new SCADA subscription if needed. + /// + /// The tag address. + /// The client identifier. + 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 { 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; + } + } + } + + /// + /// Handles tag value changes and delivers updates to all subscribed clients. + /// + /// The tag address. + /// The value, timestamp, and quality. + 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(); + } + } + + /// + /// Gets current subscription statistics for all clients and tags. + /// + /// A object containing statistics. + 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(); + } + } + + /// + /// Handles SCADA client connection state changes and notifies clients of disconnection. + /// + /// The event sender. + /// The connection state change event arguments. + 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"); + } + }); + } + } + + /// + /// Notifies all clients of a SCADA disconnection by sending bad quality updates. + /// + 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); + } + } + } + } + } + } + + /// + /// Represents a client's subscription, including channel, addresses, and statistics. + /// + private class ClientSubscription + { + /// + /// Gets or sets the client identifier. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the channel for delivering tag updates. + /// + public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!; + + /// + /// Gets or sets the set of addresses the client is subscribed to. + /// + public HashSet Addresses { get; set; } = new(); + + /// + /// Gets or sets the cancellation token source for the client. + /// + public CancellationTokenSource CancellationTokenSource { get; set; } = null!; + + /// + /// Gets or sets the count of delivered messages. + /// + public long DeliveredMessageCount { get; set; } + + /// + /// Gets or sets the count of dropped messages. + /// + public long DroppedMessageCount { get; set; } + } + + /// + /// Represents a tag subscription, including address, client IDs, and SCADA subscription handle. + /// + private class TagSubscription + { + /// + /// Gets or sets the tag address. + /// + public string Address { get; set; } = string.Empty; + + /// + /// Gets or sets the set of client IDs subscribed to this tag. + /// + public HashSet ClientIds { get; set; } = new(); + + /// + /// Gets or sets the SCADA subscription handle. + /// + public IAsyncDisposable ScadaSubscription { get; set; } = null!; + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj new file mode 100644 index 0000000..59ddbcf --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj @@ -0,0 +1,65 @@ + + + + net48 + Exe + 9.0 + enable + false + ZB.MOM.WW.LmxProxy.Host + ZB.MOM.WW.LmxProxy.Host + + x86 + x86 + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + ..\..\lib\ArchestrA.MXAccess.dll + true + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json new file mode 100644 index 0000000..5a3c19f --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json @@ -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" + } + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json new file mode 100644 index 0000000..a85b505 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json @@ -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" + ] + } +} diff --git a/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json new file mode 100644 index 0000000..2d65477 --- /dev/null +++ b/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json @@ -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 + } + } + ] + } +} \ No newline at end of file diff --git a/windev.md b/windev.md new file mode 100644 index 0000000..b004af0 --- /dev/null +++ b/windev.md @@ -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 --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/.git C:\src\" +``` + +### 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 +```