diff --git a/lmxproxy/EXECUTE_REBUILD.md b/lmxproxy/EXECUTE_REBUILD.md new file mode 100644 index 0000000..d7b6417 --- /dev/null +++ b/lmxproxy/EXECUTE_REBUILD.md @@ -0,0 +1,98 @@ +# LmxProxy v2 Rebuild — Execution Prompt + +Run this prompt with Claude Code from the `lmxproxy/` directory to execute all 7 phases of the rebuild autonomously. + +## Prompt + +You are executing a pre-approved implementation plan for rebuilding the LmxProxy gRPC proxy service. All design decisions have been made and documented. You do NOT need to ask for approval — execute each phase completely, then move to the next. + +### Context + +Read these documents in order before starting: + +1. `docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md` — the approved design +2. `CLAUDE.md` — project-level instructions +3. `docs/requirements/HighLevelReqs.md` — high-level requirements +4. `docs/requirements/Component-*.md` — all component requirements (10 files) +5. `docs/lmxproxy_updates.md` — authoritative v2 protocol specification + +### Execution Order + +Execute phases in this exact order. Each phase has a detailed plan in `docs/plans/`: + +1. **Phase 1**: `docs/plans/phase-1-protocol-domain-types.md` +2. **Phase 2**: `docs/plans/phase-2-host-core.md` +3. **Phase 3**: `docs/plans/phase-3-host-grpc-security-config.md` +4. **Phase 4**: `docs/plans/phase-4-host-health-metrics.md` +5. **Phase 5**: `docs/plans/phase-5-client-core.md` +6. **Phase 6**: `docs/plans/phase-6-client-extras.md` +7. **Phase 7**: `docs/plans/phase-7-integration-deployment.md` + +### How to Execute Each Phase + +For each phase: + +1. Read the phase plan document completely before writing any code. +2. Read any referenced requirements documents for that phase. +3. Execute each step in the plan in order. +4. After all steps, run `dotnet build` and `dotnet test` to verify. +5. If build or tests fail, fix the issues before proceeding. +6. Commit the phase with message: `feat(lmxproxy): phase N — ` +7. Push to remote: `git push` +8. Move to the next phase. + +### Guardrails (MUST follow) + +1. **Proto is the source of truth** — any wire format question is resolved by reading `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto`, not the code-first contracts. +2. **No v1 code in the new build** — the `src-reference/` directory is for reading only. Do not copy-paste and modify; write fresh code guided by the plan. +3. **Cross-stack tests in Phase 1** — Host proto serialize to Client code-first deserialize (and vice versa) must pass before any business logic. +4. **COM calls only on STA dispatch thread** — no `Task.Run` for COM operations. All go through the `StaDispatchThread` dispatch queue. +5. **status_code is canonical for quality** — `symbolic_name` is always derived from lookup, never independently set. +6. **Unit tests before integration** — every phase includes unit tests. Integration tests are Phase 7 only. +7. **Each phase must compile and pass tests** before the next phase begins. Do not skip failing tests. +8. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values. +9. **Do not modify requirements or design docs** — if you find a conflict, follow the design doc's resolution (section 11). +10. **Do not ask for user approval** — all decisions are pre-approved in the design document. + +### Error Recovery + +- If a build fails, read the error messages carefully, fix the code, and rebuild. +- If a test fails, fix the implementation (not the test) unless the test has a clear bug. +- If a step in the plan is ambiguous, consult the requirements document for that component. +- If the requirements are ambiguous, consult the design document's resolution table (section 11). +- If you cannot resolve an issue after 3 attempts, skip that step, leave a `// TODO: ` comment, and continue. + +### Phase 7 Special Instructions + +Phase 7 requires SSH access to windev (10.100.0.48). See `windev.md` in the repo root for connection details: +- SSH: `ssh windev` (passwordless) +- Default shell: cmd.exe, use `powershell -Command` for PowerShell +- Git and .NET SDK 10 are installed +- The existing v1 LmxProxy service is at `C:\publish\` on port 50051 + +For Veeam backups, SSH to the Veeam server: +- SSH: `ssh dohertj2@10.100.0.30` (passwordless) +- Use `Add-PSSnapin VeeamPSSnapin` for Veeam PowerShell + +### Commit Messages + +Use this format for each phase commit: + +- Phase 1: `feat(lmxproxy): phase 1 — v2 protocol types and domain model` +- Phase 2: `feat(lmxproxy): phase 2 — host core (MxAccessClient, SessionManager, SubscriptionManager)` +- Phase 3: `feat(lmxproxy): phase 3 — host gRPC server, security, configuration, service hosting` +- Phase 4: `feat(lmxproxy): phase 4 — host health monitoring, metrics, status web server` +- Phase 5: `feat(lmxproxy): phase 5 — client core (ILmxProxyClient, connection, read/write/subscribe)` +- Phase 6: `feat(lmxproxy): phase 6 — client extras (builder, factory, DI, streaming extensions)` +- Phase 7: `feat(lmxproxy): phase 7 — integration tests, deployment to windev, v1 cutover` + +### After All Phases + +When all 7 phases are complete: + +1. Run `dotnet build ZB.MOM.WW.LmxProxy.slnx` to verify the full solution builds. +2. Run `dotnet test` to verify all unit tests pass. +3. Verify the integration tests passed in Phase 7. +4. Create a final commit if any cleanup was needed. +5. Push all changes. +6. Report: total files created, total tests, build status, integration test results. diff --git a/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md b/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md new file mode 100644 index 0000000..3a82f6b --- /dev/null +++ b/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md @@ -0,0 +1,210 @@ +# LmxProxy v2 Rebuild — Design Document + +**Date**: 2026-03-21 +**Status**: Approved +**Scope**: Complete rebuild of LmxProxy Host and Client with v2 protocol + +## 1. Overview + +Rebuild the LmxProxy gRPC proxy service from scratch, implementing the v2 protocol (TypedValue + QualityCode) as defined in `docs/lmxproxy_updates.md`. The existing code in `src/` is retained as reference only. No backward compatibility with v1. + +## 2. Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| gRPC server for Host | Grpc.Core (C-core) | Only option for .NET Framework 4.8 server-side | +| Service hosting | Topshelf | Proven, already deployed, simple install/uninstall | +| Protocol version | v2 only, clean break | Small controlled client count, no value in v1 compat | +| Shared code between projects | None — fully independent | Different .NET runtimes (.NET Fx 4.8 vs .NET 10), wire compat is the contract | +| Client retry library | Polly v8+ | Building fresh on .NET 10, modern API | +| Testing strategy | Unit tests during implementation, integration tests after Client functional | Phased approach, real hardware validation on windev | + +## 3. Architecture + +### 3.1 Host (.NET Framework 4.8, x86) + +``` +Program.cs (Topshelf entry point) + └── LmxProxyService (lifecycle manager) + ├── Configuration (appsettings.json binding + validation) + ├── MxAccessClient (COM interop, STA dispatch thread) + │ ├── Connection state machine + │ ├── Read/Write with semaphore concurrency + │ ├── Subscription storage for reconnect replay + │ └── Auto-reconnect loop (5s interval) + ├── SessionManager (ConcurrentDictionary, 5-min inactivity scavenging) + ├── SubscriptionManager (per-client channels, shared MxAccess subscriptions) + ├── ApiKeyService (JSON file, FileSystemWatcher hot-reload) + ├── ScadaGrpcService (proto-generated, all 10 RPCs) + │ └── ApiKeyInterceptor (x-api-key header enforcement) + ├── PerformanceMetrics (per-op tracking, p95, 60s log) + ├── HealthCheckService (basic + detailed with test tag) + └── StatusWebServer (HTML dashboard, JSON status, health endpoint) +``` + +### 3.2 Client (.NET 10, AnyCPU) + +``` +ILmxProxyClient (public interface) + └── LmxProxyClient (partial class) + ├── Connection (GrpcChannel, protobuf-net.Grpc, 30s keep-alive) + ├── Read/Write/Subscribe operations + ├── CodeFirstSubscription (IAsyncEnumerable streaming) + ├── ClientMetrics (p95/p99, 1000-sample buffer) + └── Disposal (session disconnect, channel cleanup) + +LmxProxyClientBuilder (fluent builder, Polly v8 resilience pipeline) +ILmxProxyClientFactory + LmxProxyClientFactory (config-based creation) +ServiceCollectionExtensions (DI registrations) +StreamingExtensions (batched reads/writes, parallel processing) + +Domain/ + ├── ScadaContracts.cs (IScadaService + all DataContract messages) + ├── Quality.cs, QualityExtensions.cs + ├── Vtq.cs + └── ConnectionState.cs +``` + +### 3.3 Wire Compatibility + +The `.proto` file is the single source of truth for the wire format. Host generates server stubs from it. Client implements code-first contracts (`[DataContract]`/`[ServiceContract]`) that mirror the proto exactly — same field numbers, names, nesting, and streaming shapes. Cross-stack serialization tests verify compatibility. + +## 4. Protocol (v2) + +### 4.1 TypedValue System + +Protobuf `oneof` carrying native types: + +| Case | Proto Type | .NET Type | +|------|-----------|-----------| +| bool_value | bool | bool | +| int32_value | int32 | int | +| int64_value | int64 | long | +| float_value | float | float | +| double_value | double | double | +| string_value | string | string | +| bytes_value | bytes | byte[] | +| datetime_value | int64 (UTC Ticks) | DateTime | +| array_value | ArrayValue | typed arrays | + +Unset `oneof` = null. No string serialization heuristics. + +### 4.2 COM Variant Coercion Table + +| COM Variant Type | TypedValue Case | Notes | +|-----------------|-----------------|-------| +| VT_BOOL | bool_value | | +| VT_I2 (short) | int32_value | Widened | +| VT_I4 (int) | int32_value | | +| VT_I8 (long) | int64_value | | +| VT_UI2 (ushort) | int32_value | Widened | +| VT_UI4 (uint) | int64_value | Widened to avoid sign issues | +| VT_UI8 (ulong) | int64_value | Truncation risk logged if > long.MaxValue | +| VT_R4 (float) | float_value | | +| VT_R8 (double) | double_value | | +| VT_BSTR (string) | string_value | | +| VT_DATE (DateTime) | datetime_value | Converted to UTC Ticks | +| VT_DECIMAL | double_value | Precision loss logged | +| VT_CY (Currency) | double_value | | +| VT_NULL, VT_EMPTY, DBNull | unset oneof | Represents null | +| VT_ARRAY | array_value | Element type determines ArrayValue field | +| VT_UNKNOWN | string_value | ToString() fallback, logged as warning | + +### 4.3 QualityCode System + +`status_code` (uint32, OPC UA-compatible) is canonical. `symbolic_name` is derived from a lookup table, never set independently. + +Category derived from high bits: +- `0x00xxxxxx` = Good +- `0x40xxxxxx` = Uncertain +- `0x80xxxxxx` = Bad + +Domain `Quality` enum uses byte values for the low-order byte, with extension methods `IsGood()`, `IsBad()`, `IsUncertain()`. + +### 4.4 Error Model + +| Error Type | Mechanism | Examples | +|-----------|-----------|----------| +| Infrastructure | gRPC StatusCode | Unauthenticated (bad API key), PermissionDenied (ReadOnly write), InvalidArgument (bad session), Unavailable (MxAccess down) | +| Business outcome | Payload `success`/`message` fields | Tag read failure, write type mismatch, batch partial failure, WriteBatchAndWait flag timeout | +| Subscription | gRPC StatusCode on stream | Unauthenticated (invalid session), Internal (unexpected error) | + +## 5. COM Threading Model + +MxAccess is an STA COM component. All COM operations execute on a **dedicated STA thread** with a `BlockingCollection` dispatch queue: + +- `MxAccessClient` creates a single STA thread at construction +- All COM calls (connect, read, write, subscribe, disconnect) are dispatched to this thread via the queue +- Callers await a `TaskCompletionSource` that the STA thread completes after the COM call +- The STA thread runs a message pump loop (`Application.Run` or manual `MSG` pump) +- On disposal, a sentinel is enqueued and the thread joins with a 10-second timeout + +This replaces the fragile `Task.Run` + `SemaphoreSlim` pattern in the reference code. + +## 6. Session Lifecycle + +- Sessions created on `Connect` with GUID "N" format (32-char hex) +- Tracked in `ConcurrentDictionary` +- **Inactivity scavenging**: sessions not accessed for 5 minutes are automatically terminated. Client keep-alive pings (30s) keep legitimate sessions alive. +- On termination: subscriptions cleaned up, session removed from dictionary +- All sessions lost on service restart (in-memory only) + +## 7. Subscription Semantics + +- **Shared MxAccess subscriptions**: first client to subscribe creates the underlying MxAccess subscription. Last to unsubscribe disposes it. Ref-counted. +- **Sampling rate**: when multiple clients subscribe to the same tag with different `sampling_ms`, the fastest (lowest non-zero) rate is used for the MxAccess subscription. All clients receive updates at this rate. +- **Per-client channels**: each client gets an independent `BoundedChannel` (capacity 1000, DropOldest). One slow consumer's drops do not affect other clients. +- **MxAccess disconnect**: all subscribed clients receive a bad-quality notification for all their subscribed tags. +- **Session termination**: all subscriptions for that session are cleaned up. + +## 8. Authentication + +- `x-api-key` gRPC metadata header is the authoritative authentication mechanism +- `ConnectRequest.api_key` is accepted but the interceptor is the enforcement point +- API keys loaded from JSON file with FileSystemWatcher hot-reload (1-second debounce) +- Auto-generates default file with two random keys (ReadOnly + ReadWrite) if missing +- Write-protected RPCs: Write, WriteBatch, WriteBatchAndWait + +## 9. Phasing + +| Phase | Scope | Depends On | +|-------|-------|------------| +| 1 | Protocol & Domain Types | — | +| 2 | Host Core (MxAccessClient, SessionManager, SubscriptionManager) | Phase 1 | +| 3 | Host gRPC Server, Security, Configuration, Service Hosting | Phase 2 | +| 4 | Host Health, Metrics, Status Server | Phase 3 | +| 5 | Client Core | Phase 1 | +| 6 | Client Extras (Builder, Factory, DI, Streaming) | Phase 5 | +| 7 | Integration Tests & Deployment | Phases 4 + 6 | + +Phases 2-4 (Host) and 5-6 (Client) can proceed in parallel after Phase 1. + +## 10. Guardrails + +1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts. +2. **No v1 code in the new build** — reference only. Do not copy-paste and modify; write fresh. +3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic. +4. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the dispatch queue. +5. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set. +6. **Unit tests before integration** — every phase includes unit tests. Integration tests are Phase 7 only. +7. **Each phase must compile and pass tests** before the next phase begins. +8. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values. + +## 11. Resolved Conflicts + +| Conflict | Resolution | +|----------|-----------| +| WriteBatchAndWait signature (MxAccessClient vs Protocol) | Follow Protocol spec: write items, poll flagTag for flagValue. IScadaClient interface matches protocol semantics. | +| Builder default port 5050 vs Host 50051 | Standardize builder default to 50051 | +| Auth in metadata vs payload | x-api-key header is authoritative; ConnectRequest.api_key accepted but interceptor enforces | + +## 12. Reference Code + +The existing code remains in `src/` as `src-reference/` for consultation: +- `src-reference/ZB.MOM.WW.LmxProxy.Host/` — v1 Host implementation +- `src-reference/ZB.MOM.WW.LmxProxy.Client/` — v1 Client implementation + +Key reference files for COM interop patterns: +- `Implementation/MxAccessClient.Connection.cs` — COM object lifecycle +- `Implementation/MxAccessClient.EventHandlers.cs` — MxAccess callbacks +- `Implementation/MxAccessClient.Subscription.cs` — Advise/Unadvise patterns diff --git a/lmxproxy/docs/plans/phase-1-protocol-domain-types.md b/lmxproxy/docs/plans/phase-1-protocol-domain-types.md new file mode 100644 index 0000000..e560b8f --- /dev/null +++ b/lmxproxy/docs/plans/phase-1-protocol-domain-types.md @@ -0,0 +1,2723 @@ +# Phase 1: Protocol & Domain Types — Implementation Plan + +## Prerequisites + +### Rename existing source to reference +```bash +cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy +mv src src-reference +mkdir -p src/ZB.MOM.WW.LmxProxy.Host +mkdir -p src/ZB.MOM.WW.LmxProxy.Client +mkdir -p tests/ZB.MOM.WW.LmxProxy.Host.Tests +mkdir -p tests/ZB.MOM.WW.LmxProxy.Client.Tests +``` + +### Update solution file + +Overwrite `ZB.MOM.WW.LmxProxy.slnx` with: + +```xml + + + + + + + + + + +``` + +## Guardrails + +1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts. +2. **No v1 code in the new build** — `src-reference/` is for consultation only. Do not copy-paste and modify; write fresh. +3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic. +4. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set. +5. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values. +6. **Each phase must compile and pass tests** before the next phase begins. +7. **No references to old namespaces** — `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` must not appear anywhere. + +--- + +## Step 1: Create project files + +### 1.1 Host csproj + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj` + +```xml + + + + 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 + + + + +``` + +**Note**: Grpc.Tools bumped to 2.71.0 for latest protoc codegen. Google.Protobuf bumped to 3.29.3 for latest runtime. Polly stays at 7.x because .NET Framework 4.8 does not support Polly v8's modern pipeline API. Newtonsoft.Json added for API key file serialization (System.Text.Json is not well-supported on net48). + +### 1.2 Client csproj + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj` + +```xml + + + + net10.0 + latest + enable + ZB.MOM.WW.LmxProxy.Client + ZB.MOM.WW.LmxProxy.Client + true + true + gRPC client library for LmxProxy SCADA proxy service + AnyCPU + AnyCPU + + + + + + + + + + + + + + +``` + +### 1.3 Host.Tests csproj + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj` + +```xml + + + + net48 + 9.0 + enable + false + ZB.MOM.WW.LmxProxy.Host.Tests + x86 + x86 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + +``` + +### 1.4 Client.Tests csproj + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj` + +```xml + + + + net10.0 + latest + enable + false + ZB.MOM.WW.LmxProxy.Client.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + +``` + +**Important note on cross-stack tests**: The Client.Tests project includes the Host's proto file with `GrpcServices="None"` (message types only, no service stubs). This lets us serialize using Google.Protobuf generated types and deserialize using protobuf-net code-first types, verifying wire compatibility without a running gRPC server. + +--- + +## Step 2: Write v2 proto file + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` + +Create the directory first: `mkdir -p src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos` + +Write the complete v2 proto file exactly as follows: + +```protobuf +syntax = "proto3"; +package scada; + +// ============================================================ +// Service Definition +// ============================================================ + +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); +} + +// ============================================================ +// Typed Value System +// ============================================================ + +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; + int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01) + ArrayValue array_value = 9; + } +} + +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; } + +// ============================================================ +// OPC UA-Style Quality Codes +// ============================================================ + +message QualityCode { + uint32 status_code = 1; + string symbolic_name = 2; +} + +// ============================================================ +// Connection Lifecycle +// ============================================================ + +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 +// ============================================================ + +message VtqMessage { + string tag = 1; + TypedValue value = 2; + int64 timestamp_utc_ticks = 3; + QualityCode quality = 4; +} + +// ============================================================ +// Read Operations +// ============================================================ + +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 Operations +// ============================================================ + +message WriteRequest { + string session_id = 1; + string tag = 2; + TypedValue value = 3; +} + +message WriteResponse { + bool success = 1; + string message = 2; +} + +message WriteItem { + string tag = 1; + TypedValue 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; +} + +// ============================================================ +// WriteBatchAndWait +// ============================================================ + +message WriteBatchAndWaitRequest { + string session_id = 1; + repeated WriteItem items = 2; + string flag_tag = 3; + TypedValue 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 +// ============================================================ + +message SubscribeRequest { + string session_id = 1; + repeated string tags = 2; + int32 sampling_ms = 3; +} +``` + +--- + +## Step 3: Host domain types + +All Host domain types go in namespace `ZB.MOM.WW.LmxProxy.Host.Domain`. + +### 3.1 Quality enum + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs` + +```csharp +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 DA quality code, + /// enabling direct round-trip between the domain enum and the wire OPC DA byte. + /// + 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, + + /// 0x20 - Bad [Waiting for Initial Data] + Bad_WaitingForInitialData = 32, + + // ──────────── 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 + } +} +``` + +### 3.2 QualityExtensions + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Extension methods for the enum. + /// + public static class QualityExtensions + { + /// Returns true if quality is in the Good family (byte >= 192). + public static bool IsGood(this Quality q) => (byte)q >= 192; + + /// Returns true if quality is in the Uncertain family (byte 64-127). + public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128; + + /// Returns true if quality is in the Bad family (byte < 64). + public static bool IsBad(this Quality q) => (byte)q < 64; + } +} +``` + +### 3.3 Vtq + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs` + +```csharp +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. Null represents an unset/missing value. + public object? Value { get; } + + /// Gets the UTC timestamp when the value was read. + public DateTime Timestamp { get; } + + /// Gets the quality of the value. + public Quality Quality { get; } + + public Vtq(object? value, DateTime timestamp, Quality quality) + { + Value = value; + Timestamp = timestamp; + Quality = quality; + } + + public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); + public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); + public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); + + public bool Equals(Vtq other) => + Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; + + public override bool Equals(object obj) => obj is Vtq other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int hashCode = Value != null ? Value.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)Quality; + return hashCode; + } + } + + public override string ToString() => + $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; + + public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); + public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); + } +} +``` + +### 3.4 ConnectionState + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Represents the state of a SCADA client connection. + /// + public enum ConnectionState + { + Disconnected, + Connecting, + Connected, + Disconnecting, + Error, + Reconnecting + } +} +``` + +### 3.5 ConnectionStateChangedEventArgs + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs` + +```csharp +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Event arguments for SCADA client connection state changes. + /// + public class ConnectionStateChangedEventArgs : EventArgs + { + public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, + string? message = null) + { + PreviousState = previousState; + CurrentState = currentState; + Timestamp = DateTime.UtcNow; + Message = message; + } + + public ConnectionState PreviousState { get; } + public ConnectionState CurrentState { get; } + public DateTime Timestamp { get; } + public string? Message { get; } + } +} +``` + +### 3.6 IScadaClient interface + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs` + +This is the v2 interface. Note the `WriteBatchAndWaitAsync` signature now matches the v2 protocol semantics (write items, poll flagTag for flagValue). + +```csharp +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 (MxAccess wrapper). + /// + public interface IScadaClient : IAsyncDisposable + { + /// Gets whether the client is connected to MxAccess. + bool IsConnected { get; } + + /// Gets the current connection state. + ConnectionState ConnectionState { get; } + + /// Occurs when the connection state changes. + event EventHandler ConnectionStateChanged; + + /// Connects to MxAccess. + Task ConnectAsync(CancellationToken ct = default); + + /// Disconnects from MxAccess. + Task DisconnectAsync(CancellationToken ct = default); + + /// Reads a single tag value. + /// VTQ with typed value. + Task ReadAsync(string address, CancellationToken ct = default); + + /// Reads multiple tag values with semaphore-controlled concurrency. + /// Dictionary of address to VTQ. + Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); + + /// Writes a single tag value. Value is a native .NET type (not string). + Task WriteAsync(string address, object value, CancellationToken ct = default); + + /// Writes multiple tag values with semaphore-controlled concurrency. + Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); + + /// + /// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires. + /// Returns (writeSuccess, flagReached, elapsedMs). + /// + /// Tag-value pairs to write. + /// Tag to poll after writes. + /// Expected value (type-aware comparison). + /// Max wait time in milliseconds. + /// Poll interval in milliseconds. + /// Cancellation token. + Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, + string flagTag, + object flagValue, + int timeoutMs, + int pollIntervalMs, + CancellationToken ct = default); + + /// Subscribes to value changes for specified addresses. + /// Subscription handle for unsubscribing. + Task SubscribeAsync( + IEnumerable addresses, + Action callback, + CancellationToken ct = default); + } +} +``` + +### 3.7 Placeholder Program.cs (so Host compiles) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host +{ + internal static class Program + { + static void Main(string[] args) + { + // Placeholder - Phase 3 will implement full Topshelf startup. + } + } +} +``` + +### 3.8 Placeholder appsettings.json (so Host compiles) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` + +```json +{ +} +``` + +--- + +## Step 4: Client domain types + +All Client domain types go in namespace `ZB.MOM.WW.LmxProxy.Client.Domain`. + +### 4.1 Quality enum + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs` + +Identical enum values as Host (same byte values), but in the Client namespace. File-scoped namespace for .NET 10: + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// +/// OPC-style quality codes for SCADA data values. +/// Byte value matches OPC DA quality low byte for direct round-trip. +/// +public enum Quality : byte +{ + // ─────────────── Bad family (0-31) ─────────────── + Bad = 0, + Bad_ConfigError = 4, + Bad_NotConnected = 8, + Bad_DeviceFailure = 12, + Bad_SensorFailure = 16, + Bad_LastKnownValue = 20, + Bad_CommFailure = 24, + Bad_OutOfService = 28, + Bad_WaitingForInitialData = 32, + + // ──────────── Uncertain family (64-95) ─────────── + Uncertain = 64, + Uncertain_LowLimited = 65, + Uncertain_HighLimited = 66, + Uncertain_Constant = 67, + Uncertain_LastUsable = 68, + Uncertain_LastUsable_LL = 69, + Uncertain_LastUsable_HL = 70, + Uncertain_LastUsable_Cnst = 71, + Uncertain_SensorNotAcc = 80, + Uncertain_SensorNotAcc_LL = 81, + Uncertain_SensorNotAcc_HL = 82, + Uncertain_SensorNotAcc_C = 83, + Uncertain_EuExceeded = 84, + Uncertain_EuExceeded_LL = 85, + Uncertain_EuExceeded_HL = 86, + Uncertain_EuExceeded_C = 87, + Uncertain_SubNormal = 88, + Uncertain_SubNormal_LL = 89, + Uncertain_SubNormal_HL = 90, + Uncertain_SubNormal_C = 91, + + // ─────────────── Good family (192-219) ──────────── + Good = 192, + Good_LowLimited = 193, + Good_HighLimited = 194, + Good_Constant = 195, + Good_LocalOverride = 216, + Good_LocalOverride_LL = 217, + Good_LocalOverride_HL = 218, + Good_LocalOverride_C = 219 +} +``` + +### 4.2 QualityExtensions + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// Extension methods for . +public static class QualityExtensions +{ + /// Returns true if quality is in the Good family (byte >= 192). + public static bool IsGood(this Quality q) => (byte)q >= 192; + + /// Returns true if quality is in the Uncertain family (byte 64-127). + public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128; + + /// Returns true if quality is in the Bad family (byte < 64). + public static bool IsBad(this Quality q) => (byte)q < 64; +} +``` + +### 4.3 Vtq + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// Value, Timestamp, and Quality for SCADA data. +public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) +{ + public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); + public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); + public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); + + public override string ToString() => + $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; +} +``` + +### 4.4 ConnectionState + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.Domain; + +/// Represents the state of a connection to the LmxProxy service. +public enum ConnectionState +{ + Disconnected, + Connecting, + Connected, + Disconnecting, + Error, + Reconnecting +} +``` + +### 4.5 ScadaContracts.cs (v2 code-first contracts) + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs` + +This is the complete v2 code-first contract file. Every `[DataMember(Order = N)]` matches the proto field number exactly. `TypedValue` and `QualityCode` are new additions. The `IScadaService` method names follow protobuf-net.Grpc conventions (operation name matching). + +```csharp +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 +// ──────────────────────────────────────────────────────────────── + +[ServiceContract(Name = "scada.ScadaService")] +public interface IScadaService +{ + ValueTask ConnectAsync(ConnectRequest request); + ValueTask DisconnectAsync(DisconnectRequest request); + ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); + ValueTask ReadAsync(ReadRequest request); + ValueTask ReadBatchAsync(ReadBatchRequest request); + ValueTask WriteAsync(WriteRequest request); + ValueTask WriteBatchAsync(WriteBatchRequest request); + ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); + IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); + ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); +} + +// ──────────────────────────────────────────────────────────────── +// Typed Value System (v2) +// ──────────────────────────────────────────────────────────────── + +/// +/// Carries a value in its native type via a protobuf oneof. +/// Exactly one property will be non-default. All-default = null value. +/// protobuf-net uses the first non-default field in field-number order for oneof. +/// +[DataContract] +public class TypedValue +{ + [DataMember(Order = 1)] + public bool BoolValue { get; set; } + + [DataMember(Order = 2)] + public int Int32Value { get; set; } + + [DataMember(Order = 3)] + public long Int64Value { get; set; } + + [DataMember(Order = 4)] + public float FloatValue { get; set; } + + [DataMember(Order = 5)] + public double DoubleValue { get; set; } + + [DataMember(Order = 6)] + public string? StringValue { get; set; } + + [DataMember(Order = 7)] + public byte[]? BytesValue { get; set; } + + [DataMember(Order = 8)] + public long DatetimeValue { get; set; } + + [DataMember(Order = 9)] + public ArrayValue? ArrayValue { get; set; } + + /// + /// Indicates which oneof case is set. Determined by checking non-default values. + /// This is NOT a wire field -- it's a convenience helper. + /// + public TypedValueCase GetValueCase() + { + // Check in reverse priority order to handle protobuf oneof semantics. + // For the oneof, only one should be set at a time. + if (ArrayValue != null) return TypedValueCase.ArrayValue; + if (DatetimeValue != 0) return TypedValueCase.DatetimeValue; + if (BytesValue != null) return TypedValueCase.BytesValue; + if (StringValue != null) return TypedValueCase.StringValue; + if (DoubleValue != 0d) return TypedValueCase.DoubleValue; + if (FloatValue != 0f) return TypedValueCase.FloatValue; + if (Int64Value != 0) return TypedValueCase.Int64Value; + if (Int32Value != 0) return TypedValueCase.Int32Value; + if (BoolValue) return TypedValueCase.BoolValue; + return TypedValueCase.None; + } +} + +/// Identifies which field in TypedValue is set. +public enum TypedValueCase +{ + None = 0, + BoolValue = 1, + Int32Value = 2, + Int64Value = 3, + FloatValue = 4, + DoubleValue = 5, + StringValue = 6, + BytesValue = 7, + DatetimeValue = 8, + ArrayValue = 9 +} + +/// Container for typed arrays. Exactly one field will be set. +[DataContract] +public class ArrayValue +{ + [DataMember(Order = 1)] + public BoolArray? BoolValues { get; set; } + + [DataMember(Order = 2)] + public Int32Array? Int32Values { get; set; } + + [DataMember(Order = 3)] + public Int64Array? Int64Values { get; set; } + + [DataMember(Order = 4)] + public FloatArray? FloatValues { get; set; } + + [DataMember(Order = 5)] + public DoubleArray? DoubleValues { get; set; } + + [DataMember(Order = 6)] + public StringArray? StringValues { get; set; } +} + +[DataContract] +public class BoolArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class Int32Array +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class Int64Array +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class FloatArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class DoubleArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +[DataContract] +public class StringArray +{ + [DataMember(Order = 1)] + public List Values { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// Quality Code (v2) +// ──────────────────────────────────────────────────────────────── + +/// +/// OPC UA-style quality code with numeric status code and symbolic name. +/// +[DataContract] +public class QualityCode +{ + [DataMember(Order = 1)] + public uint StatusCode { get; set; } + + [DataMember(Order = 2)] + public string SymbolicName { get; set; } = string.Empty; + + /// Returns true if quality category is Good (high bits 0x00). + public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000; + + /// Returns true if quality category is Uncertain (high bits 0x40). + public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000; + + /// Returns true if quality category is Bad (high bits 0x80). + public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000; +} + +// ──────────────────────────────────────────────────────────────── +// VTQ message (v2) +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class VtqMessage +{ + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public TypedValue? Value { get; set; } + + [DataMember(Order = 3)] + public long TimestampUtcTicks { get; set; } + + [DataMember(Order = 4)] + public QualityCode? Quality { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Connect +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class ConnectRequest +{ + [DataMember(Order = 1)] + public string ClientId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public string ApiKey { get; set; } = string.Empty; +} + +[DataContract] +public class ConnectResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public string SessionId { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// Disconnect +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class DisconnectRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +[DataContract] +public class DisconnectResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +// ──────────────────────────────────────────────────────────────── +// GetConnectionState +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class GetConnectionStateRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; +} + +[DataContract] +public class GetConnectionStateResponse +{ + [DataMember(Order = 1)] + public bool IsConnected { get; set; } + + [DataMember(Order = 2)] + public string ClientId { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public long ConnectedSinceUtcTicks { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Read +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class ReadRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; +} + +[DataContract] +public class ReadResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public VtqMessage? Vtq { get; set; } +} + +[DataContract] +public class ReadBatchRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public List Tags { get; set; } = []; +} + +[DataContract] +public class ReadBatchResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public List Vtqs { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// Write +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class WriteRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public string Tag { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public TypedValue? Value { get; set; } +} + +[DataContract] +public class WriteResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} + +[DataContract] +public class WriteItem +{ + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public TypedValue? Value { get; set; } +} + +[DataContract] +public class WriteResult +{ + [DataMember(Order = 1)] + public string Tag { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public bool Success { get; set; } + + [DataMember(Order = 3)] + public string Message { get; set; } = string.Empty; +} + +[DataContract] +public class WriteBatchRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public List Items { get; set; } = []; +} + +[DataContract] +public class WriteBatchResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public List Results { get; set; } = []; +} + +// ──────────────────────────────────────────────────────────────── +// WriteBatchAndWait +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class WriteBatchAndWaitRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public List Items { get; set; } = []; + + [DataMember(Order = 3)] + public string FlagTag { get; set; } = string.Empty; + + [DataMember(Order = 4)] + public TypedValue? FlagValue { get; set; } + + [DataMember(Order = 5)] + public int TimeoutMs { get; set; } + + [DataMember(Order = 6)] + public int PollIntervalMs { get; set; } +} + +[DataContract] +public class WriteBatchAndWaitResponse +{ + [DataMember(Order = 1)] + public bool Success { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; + + [DataMember(Order = 3)] + public List WriteResults { get; set; } = []; + + [DataMember(Order = 4)] + public bool FlagReached { get; set; } + + [DataMember(Order = 5)] + public int ElapsedMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// Subscribe +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class SubscribeRequest +{ + [DataMember(Order = 1)] + public string SessionId { get; set; } = string.Empty; + + [DataMember(Order = 2)] + public List Tags { get; set; } = []; + + [DataMember(Order = 3)] + public int SamplingMs { get; set; } +} + +// ──────────────────────────────────────────────────────────────── +// CheckApiKey +// ──────────────────────────────────────────────────────────────── + +[DataContract] +public class CheckApiKeyRequest +{ + [DataMember(Order = 1)] + public string ApiKey { get; set; } = string.Empty; +} + +[DataContract] +public class CheckApiKeyResponse +{ + [DataMember(Order = 1)] + public bool IsValid { get; set; } + + [DataMember(Order = 2)] + public string Message { get; set; } = string.Empty; +} +``` + +--- + +## Step 5: COM variant coercion helpers (Host-side) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs` + +This static class converts between COM variant objects (boxed .NET types from MxAccess) and the proto-generated `Scada.TypedValue` message. The proto codegen will produce classes in namespace `Scada` (from `package scada;`). + +```csharp +using System; +using Google.Protobuf; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Converts between COM variant objects (boxed .NET types from MxAccess) + /// and proto-generated messages. + /// + public static class TypedValueConverter + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter)); + + /// + /// Converts a COM variant object to a proto TypedValue. + /// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL. + /// + public static Scada.TypedValue? ToTypedValue(object? value) + { + if (value == null || value is DBNull) + return null; + + switch (value) + { + case bool b: + return new Scada.TypedValue { BoolValue = b }; + + case short s: // VT_I2 → widened to int32 + return new Scada.TypedValue { Int32Value = s }; + + case int i: // VT_I4 + return new Scada.TypedValue { Int32Value = i }; + + case long l: // VT_I8 + return new Scada.TypedValue { Int64Value = l }; + + case ushort us: // VT_UI2 → widened to int32 + return new Scada.TypedValue { Int32Value = us }; + + case uint ui: // VT_UI4 → widened to int64 to avoid sign issues + return new Scada.TypedValue { Int64Value = ui }; + + case ulong ul: // VT_UI8 → int64, truncation risk + if (ul > (ulong)long.MaxValue) + Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul); + return new Scada.TypedValue { Int64Value = (long)ul }; + + case float f: // VT_R4 + return new Scada.TypedValue { FloatValue = f }; + + case double d: // VT_R8 + return new Scada.TypedValue { DoubleValue = d }; + + case string str: // VT_BSTR + return new Scada.TypedValue { StringValue = str }; + + case DateTime dt: // VT_DATE → UTC Ticks + return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks }; + + case decimal dec: // VT_DECIMAL → double (precision loss) + Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec); + return new Scada.TypedValue { DoubleValue = (double)dec }; + + case byte[] bytes: // VT_ARRAY of bytes + return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) }; + + case bool[] boolArr: + { + var arr = new Scada.BoolArray(); + arr.Values.AddRange(boolArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } }; + } + + case int[] intArr: + { + var arr = new Scada.Int32Array(); + arr.Values.AddRange(intArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } }; + } + + case long[] longArr: + { + var arr = new Scada.Int64Array(); + arr.Values.AddRange(longArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } }; + } + + case float[] floatArr: + { + var arr = new Scada.FloatArray(); + arr.Values.AddRange(floatArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } }; + } + + case double[] doubleArr: + { + var arr = new Scada.DoubleArray(); + arr.Values.AddRange(doubleArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } }; + } + + case string[] strArr: + { + var arr = new Scada.StringArray(); + arr.Values.AddRange(strArr); + return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } }; + } + + default: + // VT_UNKNOWN or any unrecognized type — ToString() fallback + Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name); + return new Scada.TypedValue { StringValue = value.ToString() }; + } + } + + /// + /// Converts a proto TypedValue back to a boxed .NET object. + /// Returns null for unset oneof (null TypedValue or ValueCase.None). + /// + public static object? FromTypedValue(Scada.TypedValue? typedValue) + { + if (typedValue == null) + return null; + + switch (typedValue.ValueCase) + { + case Scada.TypedValue.ValueOneofCase.BoolValue: + return typedValue.BoolValue; + + case Scada.TypedValue.ValueOneofCase.Int32Value: + return typedValue.Int32Value; + + case Scada.TypedValue.ValueOneofCase.Int64Value: + return typedValue.Int64Value; + + case Scada.TypedValue.ValueOneofCase.FloatValue: + return typedValue.FloatValue; + + case Scada.TypedValue.ValueOneofCase.DoubleValue: + return typedValue.DoubleValue; + + case Scada.TypedValue.ValueOneofCase.StringValue: + return typedValue.StringValue; + + case Scada.TypedValue.ValueOneofCase.BytesValue: + return typedValue.BytesValue.ToByteArray(); + + case Scada.TypedValue.ValueOneofCase.DatetimeValue: + return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc); + + case Scada.TypedValue.ValueOneofCase.ArrayValue: + return FromArrayValue(typedValue.ArrayValue); + + case Scada.TypedValue.ValueOneofCase.None: + default: + return null; + } + } + + private static object? FromArrayValue(Scada.ArrayValue? arrayValue) + { + if (arrayValue == null) + return null; + + switch (arrayValue.ValuesCase) + { + case Scada.ArrayValue.ValuesOneofCase.BoolValues: + return arrayValue.BoolValues?.Values?.Count > 0 + ? ToArray(arrayValue.BoolValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.Int32Values: + return arrayValue.Int32Values?.Values?.Count > 0 + ? ToArray(arrayValue.Int32Values.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.Int64Values: + return arrayValue.Int64Values?.Values?.Count > 0 + ? ToArray(arrayValue.Int64Values.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.FloatValues: + return arrayValue.FloatValues?.Values?.Count > 0 + ? ToArray(arrayValue.FloatValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.DoubleValues: + return arrayValue.DoubleValues?.Values?.Count > 0 + ? ToArray(arrayValue.DoubleValues.Values) + : Array.Empty(); + + case Scada.ArrayValue.ValuesOneofCase.StringValues: + return arrayValue.StringValues?.Values?.Count > 0 + ? ToArray(arrayValue.StringValues.Values) + : Array.Empty(); + + default: + return null; + } + } + + private static T[] ToArray(Google.Protobuf.Collections.RepeatedField repeatedField) + { + var result = new T[repeatedField.Count]; + for (int i = 0; i < repeatedField.Count; i++) + result[i] = repeatedField[i]; + return result; + } + } +} +``` + +--- + +## Step 6: QualityCode helpers (Host-side) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs` + +Maps between the domain `Quality` enum and proto `QualityCode` messages. + +```csharp +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Maps between the domain enum and proto QualityCode messages. + /// status_code (uint32) is canonical. symbolic_name is derived from a lookup table. + /// + public static class QualityCodeMapper + { + /// OPC UA status code → symbolic name lookup. + private static readonly Dictionary StatusCodeToName = new Dictionary + { + // Good + { 0x00000000, "Good" }, + { 0x00D80000, "GoodLocalOverride" }, + + // Uncertain + { 0x40900000, "UncertainLastUsableValue" }, + { 0x42390000, "UncertainSensorNotAccurate" }, + { 0x40540000, "UncertainEngineeringUnitsExceeded" }, + { 0x40580000, "UncertainSubNormal" }, + + // Bad + { 0x80000000, "Bad" }, + { 0x80040000, "BadConfigurationError" }, + { 0x808A0000, "BadNotConnected" }, + { 0x806B0000, "BadDeviceFailure" }, + { 0x806D0000, "BadSensorFailure" }, + { 0x80050000, "BadCommunicationFailure" }, + { 0x808F0000, "BadOutOfService" }, + { 0x80320000, "BadWaitingForInitialData" }, + }; + + /// Domain Quality enum → OPC UA status code. + private static readonly Dictionary QualityToStatusCode = new Dictionary + { + // Good family + { Quality.Good, 0x00000000 }, + { Quality.Good_LowLimited, 0x00000000 }, + { Quality.Good_HighLimited, 0x00000000 }, + { Quality.Good_Constant, 0x00000000 }, + { Quality.Good_LocalOverride, 0x00D80000 }, + { Quality.Good_LocalOverride_LL, 0x00D80000 }, + { Quality.Good_LocalOverride_HL, 0x00D80000 }, + { Quality.Good_LocalOverride_C, 0x00D80000 }, + + // Uncertain family + { Quality.Uncertain, 0x40900000 }, + { Quality.Uncertain_LowLimited, 0x40900000 }, + { Quality.Uncertain_HighLimited, 0x40900000 }, + { Quality.Uncertain_Constant, 0x40900000 }, + { Quality.Uncertain_LastUsable, 0x40900000 }, + { Quality.Uncertain_LastUsable_LL, 0x40900000 }, + { Quality.Uncertain_LastUsable_HL, 0x40900000 }, + { Quality.Uncertain_LastUsable_Cnst, 0x40900000 }, + { Quality.Uncertain_SensorNotAcc, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_LL, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_HL, 0x42390000 }, + { Quality.Uncertain_SensorNotAcc_C, 0x42390000 }, + { Quality.Uncertain_EuExceeded, 0x40540000 }, + { Quality.Uncertain_EuExceeded_LL, 0x40540000 }, + { Quality.Uncertain_EuExceeded_HL, 0x40540000 }, + { Quality.Uncertain_EuExceeded_C, 0x40540000 }, + { Quality.Uncertain_SubNormal, 0x40580000 }, + { Quality.Uncertain_SubNormal_LL, 0x40580000 }, + { Quality.Uncertain_SubNormal_HL, 0x40580000 }, + { Quality.Uncertain_SubNormal_C, 0x40580000 }, + + // Bad family + { Quality.Bad, 0x80000000 }, + { Quality.Unknown, 0x80000000 }, + { Quality.Bad_ConfigError, 0x80040000 }, + { Quality.Bad_NotConnected, 0x808A0000 }, + { Quality.Bad_DeviceFailure, 0x806B0000 }, + { Quality.Bad_SensorFailure, 0x806D0000 }, + { Quality.Bad_LastKnownValue, 0x80050000 }, + { Quality.Bad_CommFailure, 0x80050000 }, + { Quality.Bad_OutOfService, 0x808F0000 }, + { Quality.Bad_WaitingForInitialData, 0x80320000 }, + }; + + /// + /// Converts a domain Quality enum to a proto QualityCode message. + /// + public static Scada.QualityCode ToQualityCode(Quality quality) + { + var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u; + var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad"; + + return new Scada.QualityCode + { + StatusCode = statusCode, + SymbolicName = symbolicName + }; + } + + /// + /// Converts an OPC UA status code (uint32) to a domain Quality enum. + /// Falls back to the nearest category if the specific code is not mapped. + /// + public static Quality FromStatusCode(uint statusCode) + { + // Exact match first — iterate QualityToStatusCode to find matching Quality + foreach (var kvp in QualityToStatusCode) + { + if (kvp.Value == statusCode) + return kvp.Key; + } + + // Category fallback + uint category = statusCode & 0xC0000000; + if (category == 0x00000000) return Quality.Good; + if (category == 0x40000000) return Quality.Uncertain; + return Quality.Bad; + } + + /// + /// Gets the symbolic name for a status code. + /// + public static string GetSymbolicName(uint statusCode) + { + if (StatusCodeToName.TryGetValue(statusCode, out var name)) + return name; + + uint category = statusCode & 0xC0000000; + if (category == 0x00000000) return "Good"; + if (category == 0x40000000) return "Uncertain"; + return "Bad"; + } + + /// + /// Creates a QualityCode for a specific well-known status. + /// + public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; + public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; + public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" }; + public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }; + public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" }; + public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" }; + public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }; + public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" }; + public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" }; + public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" }; + public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; + } +} +``` + +--- + +## Step 7: Unit tests + +### 7.1 Host.Tests: TypedValueConverter tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs` + +```csharp +using System; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class TypedValueConverterTests + { + [Fact] + public void Null_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(null); + tv.Should().BeNull(); + TypedValueConverter.FromTypedValue(null).Should().BeNull(); + } + + [Fact] + public void DBNull_MapsToNull() + { + var tv = TypedValueConverter.ToTypedValue(DBNull.Value); + tv.Should().BeNull(); + } + + [Fact] + public void Bool_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(true); + tv.Should().NotBeNull(); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue); + tv.BoolValue.Should().BeTrue(); + TypedValueConverter.FromTypedValue(tv).Should().Be(true); + + var tvFalse = TypedValueConverter.ToTypedValue(false); + tvFalse!.BoolValue.Should().BeFalse(); + TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false); + } + + [Fact] + public void Short_WidensToInt32() + { + var tv = TypedValueConverter.ToTypedValue((short)42); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(42); + TypedValueConverter.FromTypedValue(tv).Should().Be(42); + } + + [Fact] + public void Int_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(int.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(int.MaxValue); + TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue); + } + + [Fact] + public void Long_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(long.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(long.MaxValue); + TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue); + } + + [Fact] + public void UShort_WidensToInt32() + { + var tv = TypedValueConverter.ToTypedValue((ushort)65535); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); + tv.Int32Value.Should().Be(65535); + } + + [Fact] + public void UInt_WidensToInt64() + { + var tv = TypedValueConverter.ToTypedValue(uint.MaxValue); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(uint.MaxValue); + } + + [Fact] + public void ULong_MapsToInt64() + { + var tv = TypedValueConverter.ToTypedValue((ulong)12345678); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); + tv.Int64Value.Should().Be(12345678); + } + + [Fact] + public void Float_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(3.14159f); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue); + tv.FloatValue.Should().Be(3.14159f); + TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f); + } + + [Fact] + public void Double_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue(2.718281828459045); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); + tv.DoubleValue.Should().Be(2.718281828459045); + TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045); + } + + [Fact] + public void String_RoundTrips() + { + var tv = TypedValueConverter.ToTypedValue("Hello World"); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); + tv.StringValue.Should().Be("Hello World"); + TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World"); + } + + [Fact] + public void DateTime_RoundTrips_AsUtcTicks() + { + var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc); + var tv = TypedValueConverter.ToTypedValue(dt); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue); + tv.DatetimeValue.Should().Be(dt.Ticks); + var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!; + result.Kind.Should().Be(DateTimeKind.Utc); + result.Ticks.Should().Be(dt.Ticks); + } + + [Fact] + public void ByteArray_RoundTrips() + { + var bytes = new byte[] { 0x00, 0xFF, 0x42 }; + var tv = TypedValueConverter.ToTypedValue(bytes); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue); + var result = (byte[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(bytes); + } + + [Fact] + public void Decimal_MapsToDouble() + { + var tv = TypedValueConverter.ToTypedValue(123.456m); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); + tv.DoubleValue.Should().BeApproximately(123.456, 0.001); + } + + [Fact] + public void FloatArray_RoundTrips() + { + var arr = new float[] { 1.0f, 2.0f, 3.0f }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (float[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void IntArray_RoundTrips() + { + var arr = new int[] { 10, 20, 30 }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (int[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void StringArray_RoundTrips() + { + var arr = new string[] { "a", "b", "c" }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (string[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void DoubleArray_RoundTrips() + { + var arr = new double[] { 1.1, 2.2, 3.3 }; + var tv = TypedValueConverter.ToTypedValue(arr); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); + var result = (double[])TypedValueConverter.FromTypedValue(tv)!; + result.Should().BeEquivalentTo(arr); + } + + [Fact] + public void UnrecognizedType_FallsBackToString() + { + var guid = Guid.NewGuid(); + var tv = TypedValueConverter.ToTypedValue(guid); + tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); + tv.StringValue.Should().Be(guid.ToString()); + } + } +} +``` + +### 7.2 Host.Tests: QualityCodeMapper tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs` + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class QualityCodeMapperTests + { + [Theory] + [InlineData(Quality.Good, 0x00000000u, "Good")] + [InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")] + [InlineData(Quality.Bad, 0x80000000u, "Bad")] + [InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")] + [InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")] + [InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")] + [InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")] + [InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")] + [InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")] + [InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")] + [InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")] + [InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")] + [InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")] + [InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")] + public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName) + { + var qc = QualityCodeMapper.ToQualityCode(quality); + qc.StatusCode.Should().Be(expectedStatusCode); + qc.SymbolicName.Should().Be(expectedName); + } + + [Theory] + [InlineData(0x00000000u, Quality.Good)] + [InlineData(0x80000000u, Quality.Bad)] + [InlineData(0x80040000u, Quality.Bad_ConfigError)] + [InlineData(0x806D0000u, Quality.Bad_SensorFailure)] + [InlineData(0x40900000u, Quality.Uncertain_LastUsable)] + public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality) + { + QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality); + } + + [Fact] + public void FromStatusCode_UnknownGoodCode_FallsBackToGood() + { + QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good); + } + + [Fact] + public void FromStatusCode_UnknownBadCode_FallsBackToBad() + { + QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad); + } + + [Fact] + public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain() + { + QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain); + } + + [Theory] + [InlineData(0x00000000u, "Good")] + [InlineData(0x80000000u, "Bad")] + [InlineData(0x806D0000u, "BadSensorFailure")] + [InlineData(0x40900000u, "UncertainLastUsableValue")] + [InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back + public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName) + { + QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName); + } + + [Fact] + public void FactoryMethods_ReturnCorrectCodes() + { + QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u); + QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u); + QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u); + QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u); + QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u); + QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u); + QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u); + QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u); + QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u); + QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u); + QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u); + } + } +} +``` + +### 7.3 Host.Tests: Quality extensions tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs` + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain +{ + public class QualityExtensionsTests + { + [Theory] + [InlineData(Quality.Good, true)] + [InlineData(Quality.Good_LocalOverride, true)] + [InlineData(Quality.Uncertain, false)] + [InlineData(Quality.Bad, false)] + public void IsGood(Quality q, bool expected) + { + q.IsGood().Should().Be(expected); + } + + [Theory] + [InlineData(Quality.Uncertain, true)] + [InlineData(Quality.Uncertain_LastUsable, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Bad, false)] + public void IsUncertain(Quality q, bool expected) + { + q.IsUncertain().Should().Be(expected); + } + + [Theory] + [InlineData(Quality.Bad, true)] + [InlineData(Quality.Bad_CommFailure, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Uncertain, false)] + public void IsBad(Quality q, bool expected) + { + q.IsBad().Should().Be(expected); + } + } +} +``` + +### 7.4 Client.Tests: ScadaContracts tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs` + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class ScadaContractsTests +{ + [Fact] + public void TypedValue_GetValueCase_Bool() + { + var tv = new TypedValue { BoolValue = true }; + tv.GetValueCase().Should().Be(TypedValueCase.BoolValue); + } + + [Fact] + public void TypedValue_GetValueCase_Int32() + { + var tv = new TypedValue { Int32Value = 42 }; + tv.GetValueCase().Should().Be(TypedValueCase.Int32Value); + } + + [Fact] + public void TypedValue_GetValueCase_Double() + { + var tv = new TypedValue { DoubleValue = 3.14 }; + tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue); + } + + [Fact] + public void TypedValue_GetValueCase_String() + { + var tv = new TypedValue { StringValue = "hello" }; + tv.GetValueCase().Should().Be(TypedValueCase.StringValue); + } + + [Fact] + public void TypedValue_GetValueCase_None_WhenDefault() + { + var tv = new TypedValue(); + tv.GetValueCase().Should().Be(TypedValueCase.None); + } + + [Fact] + public void TypedValue_GetValueCase_Datetime() + { + var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks }; + tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue); + } + + [Fact] + public void TypedValue_GetValueCase_BytesValue() + { + var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } }; + tv.GetValueCase().Should().Be(TypedValueCase.BytesValue); + } + + [Fact] + public void TypedValue_GetValueCase_ArrayValue() + { + var tv = new TypedValue + { + ArrayValue = new ArrayValue + { + FloatValues = new FloatArray { Values = { 1.0f, 2.0f } } + } + }; + tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue); + } + + [Fact] + public void QualityCode_IsGood() + { + var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; + qc.IsGood.Should().BeTrue(); + qc.IsBad.Should().BeFalse(); + qc.IsUncertain.Should().BeFalse(); + } + + [Fact] + public void QualityCode_IsBad() + { + var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; + qc.IsGood.Should().BeFalse(); + qc.IsBad.Should().BeTrue(); + qc.IsUncertain.Should().BeFalse(); + } + + [Fact] + public void QualityCode_IsUncertain() + { + var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; + qc.IsGood.Should().BeFalse(); + qc.IsBad.Should().BeFalse(); + qc.IsUncertain.Should().BeTrue(); + } + + [Fact] + public void VtqMessage_DefaultProperties() + { + var vtq = new VtqMessage(); + vtq.Tag.Should().BeEmpty(); + vtq.Value.Should().BeNull(); + vtq.TimestampUtcTicks.Should().Be(0); + vtq.Quality.Should().BeNull(); + } + + [Fact] + public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue() + { + var req = new WriteBatchAndWaitRequest + { + SessionId = "abc", + FlagTag = "Motor.Done", + FlagValue = new TypedValue { BoolValue = true }, + TimeoutMs = 5000, + PollIntervalMs = 100 + }; + req.FlagValue.Should().NotBeNull(); + req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue); + } + + [Fact] + public void WriteItem_Value_IsTypedValue() + { + var item = new WriteItem + { + Tag = "Motor.Speed", + Value = new TypedValue { DoubleValue = 42.5 } + }; + item.Value.Should().NotBeNull(); + item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue); + } +} +``` + +### 7.5 Client.Tests: Quality extensions tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs` + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class QualityExtensionsTests +{ + [Theory] + [InlineData(Quality.Good, true)] + [InlineData(Quality.Good_LocalOverride, true)] + [InlineData(Quality.Uncertain, false)] + [InlineData(Quality.Bad, false)] + public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected); + + [Theory] + [InlineData(Quality.Uncertain, true)] + [InlineData(Quality.Uncertain_LastUsable, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Bad, false)] + public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected); + + [Theory] + [InlineData(Quality.Bad, true)] + [InlineData(Quality.Bad_CommFailure, true)] + [InlineData(Quality.Good, false)] + [InlineData(Quality.Uncertain, false)] + public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected); +} +``` + +### 7.6 Client.Tests: Vtq factory methods + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs` + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; + +public class VtqTests +{ + [Fact] + public void Good_FactoryMethod() + { + var vtq = Vtq.Good(42.0); + vtq.Value.Should().Be(42.0); + vtq.Quality.Should().Be(Quality.Good); + vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc); + } + + [Fact] + public void Bad_FactoryMethod() + { + var vtq = Vtq.Bad(); + vtq.Value.Should().BeNull(); + vtq.Quality.Should().Be(Quality.Bad); + } + + [Fact] + public void Uncertain_FactoryMethod() + { + var vtq = Vtq.Uncertain("stale"); + vtq.Value.Should().Be("stale"); + vtq.Quality.Should().Be(Quality.Uncertain); + } +} +``` + +--- + +## Step 8: Cross-stack contract tests + +These tests verify that bytes serialized by Host proto-generated code can be deserialized by Client code-first code (and vice versa). The Client.Tests project includes the Host's `scada.proto` with `GrpcServices="None"` to get the Google.Protobuf generated message classes. + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs` + +```csharp +using System.IO; +using FluentAssertions; +using Google.Protobuf; +using ProtoBuf; +using Xunit; +using ProtoGenerated = Scada; +using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack; + +/// +/// Verifies wire compatibility between Host proto-generated types and Client code-first types. +/// Serializes with one stack, deserializes with the other. +/// +public class CrossStackSerializationTests +{ + // ── Proto-generated → Code-first ────────────────────────── + + [Fact] + public void VtqMessage_ProtoToCodeFirst_BoolValue() + { + // Arrange: proto-generated VtqMessage with bool TypedValue + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Running", + Value = new ProtoGenerated.TypedValue { BoolValue = true }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + // Act: serialize with proto, deserialize with protobuf-net + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + // Assert + codeFirst.Should().NotBeNull(); + codeFirst.Tag.Should().Be("Motor.Running"); + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.BoolValue.Should().BeTrue(); + codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L); + codeFirst.Quality.Should().NotBeNull(); + codeFirst.Quality!.StatusCode.Should().Be(0x00000000u); + codeFirst.Quality.SymbolicName.Should().Be("Good"); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_DoubleValue() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Speed", + Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.DoubleValue.Should().Be(42.5); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_StringValue() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Name", + Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.StringValue.Should().Be("Pump A"); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_Int32Value() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Count", + Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value!.Int32Value.Should().Be(int.MaxValue); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_BadQuality() + { + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Fault", + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u); + codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure"); + codeFirst.Quality.IsBad.Should().BeTrue(); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_NullValue() + { + // No Value field set — represents null + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Optional", + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + // When no oneof is set, the Value object may be null or all-default + // Either way, GetValueCase() should return None + if (codeFirst.Value != null) + codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None); + } + + [Fact] + public void VtqMessage_ProtoToCodeFirst_FloatArrayValue() + { + var floatArr = new ProtoGenerated.FloatArray(); + floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f }); + var protoMsg = new ProtoGenerated.VtqMessage + { + Tag = "Motor.Samples", + Value = new ProtoGenerated.TypedValue + { + ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr } + }, + TimestampUtcTicks = 638789000000000000L, + Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + + codeFirst.Value.Should().NotBeNull(); + codeFirst.Value!.ArrayValue.Should().NotBeNull(); + codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull(); + codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f }); + } + + // ── Code-first → Proto-generated ────────────────────────── + + [Fact] + public void VtqMessage_CodeFirstToProto_DoubleValue() + { + var codeFirst = new CodeFirst.VtqMessage + { + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 99.9 }, + TimestampUtcTicks = 638789000000000000L, + Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } + }; + + // Serialize with protobuf-net + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var bytes = ms.ToArray(); + + // Deserialize with Google.Protobuf + var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes); + + protoMsg.Tag.Should().Be("Motor.Speed"); + protoMsg.Value.Should().NotBeNull(); + protoMsg.Value.DoubleValue.Should().Be(99.9); + protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L); + protoMsg.Quality.StatusCode.Should().Be(0x00000000u); + } + + [Fact] + public void WriteRequest_CodeFirstToProto() + { + var codeFirst = new CodeFirst.WriteRequest + { + SessionId = "abc123", + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 42.5 } + }; + + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var bytes = ms.ToArray(); + + var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes); + protoMsg.SessionId.Should().Be("abc123"); + protoMsg.Tag.Should().Be("Motor.Speed"); + protoMsg.Value.Should().NotBeNull(); + protoMsg.Value.DoubleValue.Should().Be(42.5); + } + + [Fact] + public void ConnectRequest_RoundTrips() + { + var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" }; + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray()); + protoMsg.ClientId.Should().Be("ScadaLink-1"); + protoMsg.ApiKey.Should().Be("key-123"); + } + + [Fact] + public void ConnectResponse_RoundTrips() + { + var protoMsg = new ProtoGenerated.ConnectResponse + { + Success = true, + Message = "Connected", + SessionId = "abcdef1234567890abcdef1234567890" + }; + var bytes = protoMsg.ToByteArray(); + var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); + codeFirst.Success.Should().BeTrue(); + codeFirst.Message.Should().Be("Connected"); + codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890"); + } + + [Fact] + public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue() + { + var codeFirst = new CodeFirst.WriteBatchAndWaitRequest + { + SessionId = "sess1", + FlagTag = "Motor.Done", + FlagValue = new CodeFirst.TypedValue { BoolValue = true }, + TimeoutMs = 5000, + PollIntervalMs = 100, + Items = + { + new CodeFirst.WriteItem + { + Tag = "Motor.Speed", + Value = new CodeFirst.TypedValue { DoubleValue = 50.0 } + } + } + }; + + var ms = new MemoryStream(); + Serializer.Serialize(ms, codeFirst); + var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray()); + + protoMsg.FlagTag.Should().Be("Motor.Done"); + protoMsg.FlagValue.BoolValue.Should().BeTrue(); + protoMsg.TimeoutMs.Should().Be(5000); + protoMsg.PollIntervalMs.Should().Be(100); + protoMsg.Items.Should().HaveCount(1); + protoMsg.Items[0].Tag.Should().Be("Motor.Speed"); + protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0); + } +} +``` + +**Important note on protobuf-net oneof handling**: protobuf-net's code-first approach does not have native `oneof` support. Instead, it serializes whichever fields have non-default values. For `TypedValue`, only one field should be set at a time to maintain oneof semantics. The cross-stack tests verify this works in practice. If tests fail due to protobuf-net serializing multiple fields when only one is set (e.g., `BoolValue = false` serialized as field 1 with value 0), you may need to add `[ProtoMember]` attributes with `ShouldSerialize*` methods or use `[ProtoContract(SkipConstructor = true)]`. Investigate and fix if needed — the cross-stack tests exist precisely to catch these issues. + +--- + +## Step 9: Build verification + +Run these commands from the repository root (`/Users/dohertj2/Desktop/scadalink-design/lmxproxy`): + +```bash +# Verify solution builds (Host will likely fail on macOS due to net48/x86 — that's expected) +dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj + +# Run Client tests (these should work on macOS) +dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj + +# If on Windows, also run: +# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj +``` + +**Note on build platform**: The Host project targets `net48` with `PlatformTarget=x86`, which requires Windows with .NET Framework 4.8 SDK. On macOS, only the Client project and Client.Tests will build. The Host will be verified on the Windows development machine (windev). The cross-stack serialization tests in Client.Tests use the proto file directly (via `Grpc.Tools` codegen which works on all platforms for message-only generation) so they can run on macOS. + +--- + +## Completion Criteria + +- [ ] `src-reference/` contains old code, `src/` contains fresh v2 code only +- [ ] Solution file references all 4 projects (Host, Client, Host.Tests, Client.Tests) +- [ ] Proto file at `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` matches v2 spec exactly +- [ ] Host domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ConnectionStateChangedEventArgs, IScadaClient +- [ ] Client domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ScadaContracts (all v2 messages) +- [ ] TypedValueConverter handles all COM variant types from the coercion table +- [ ] QualityCodeMapper maps all AVEVA-relevant quality codes bidirectionally +- [ ] All Host.Tests pass (TypedValueConverter, QualityCodeMapper, QualityExtensions) +- [ ] All Client.Tests pass (ScadaContracts, QualityExtensions, Vtq factory methods) +- [ ] Cross-stack serialization tests pass (proto-generated ↔ code-first) +- [ ] No references to `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` in any new file +- [ ] No string serialization heuristics (`double.TryParse`, `bool.TryParse`) in any new file diff --git a/lmxproxy/docs/plans/phase-2-host-core.md b/lmxproxy/docs/plans/phase-2-host-core.md new file mode 100644 index 0000000..fb723ee --- /dev/null +++ b/lmxproxy/docs/plans/phase-2-host-core.md @@ -0,0 +1,2067 @@ +# Phase 2: Host Core Components — Implementation Plan + +## Prerequisites + +- Phase 1 complete and passing: all projects build, all unit tests pass, cross-stack serialization verified. +- The following Phase 1 artifacts exist and are used throughout this phase: + - `src/ZB.MOM.WW.LmxProxy.Host/Domain/` — Quality, Vtq, ConnectionState, IScadaClient, TypedValueConverter, QualityCodeMapper + - `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` — v2 proto (generates `Scada.*` classes) + +## Guardrails + +1. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the STA dispatch queue. +2. **No v1 code** — reference `src-reference/` for patterns but write fresh. +3. **status_code is canonical for quality** — use `QualityCodeMapper` for all quality conversions. +4. **No string serialization heuristics** — use `TypedValueConverter` for all value conversions. +5. **Unit tests for every component** — test before moving to Phase 3. +6. **Each step must compile** before proceeding to the next. + +--- + +## Step 1: MxAccessClient — STA Dispatch Thread + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaDispatchThread.cs` + +**Namespace**: `ZB.MOM.WW.LmxProxy.Host.MxAccess` + +This is the foundation for all COM interop. MxAccess is an STA COM component — all COM calls must execute on a dedicated STA thread with a message pump. + +### Class Design + +```csharp +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + /// + /// Dedicated STA thread with a message pump for COM interop. + /// All COM operations are dispatched to this thread via a BlockingCollection. + /// + public sealed class StaDispatchThread : IDisposable + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly BlockingCollection _workQueue = new BlockingCollection(); + private readonly Thread _staThread; + private volatile bool _disposed; + + public StaDispatchThread(string threadName = "MxAccess-STA") + { + _staThread = new Thread(StaThreadLoop) + { + Name = threadName, + IsBackground = true + }; + _staThread.SetApartmentState(ApartmentState.STA); + _staThread.Start(); + Log.Information("STA dispatch thread '{ThreadName}' started", threadName); + } + + /// + /// Dispatches an action to the STA thread and returns a Task that completes + /// when the action finishes. + /// + public Task DispatchAsync(Action action) + { + if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _workQueue.Add(() => + { + try + { + action(); + tcs.TrySetResult(true); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + return tcs.Task; + } + + /// + /// Dispatches a function to the STA thread and returns its result. + /// + public Task DispatchAsync(Func func) + { + if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _workQueue.Add(() => + { + try + { + var result = func(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + return tcs.Task; + } + + private void StaThreadLoop() + { + Log.Debug("STA thread loop started"); + + // Process the work queue. GetConsumingEnumerable blocks until + // items are available or the collection is marked complete. + foreach (var action in _workQueue.GetConsumingEnumerable()) + { + try + { + action(); + } + catch (Exception ex) + { + // Should not happen — actions set TCS exceptions internally. + Log.Error(ex, "Unhandled exception on STA thread"); + } + + // Pump COM messages between work items + Application.DoEvents(); + } + + Log.Debug("STA thread loop exited"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _workQueue.CompleteAdding(); + + // Wait for the STA thread to drain and exit + if (_staThread.IsAlive && !_staThread.Join(TimeSpan.FromSeconds(10))) + { + Log.Warning("STA thread did not exit within 10 seconds"); + } + + _workQueue.Dispose(); + Log.Information("STA dispatch thread disposed"); + } + } +} +``` + +**Key design decisions**: +- `BlockingCollection` is the dispatch queue (thread-safe, blocking consumer). +- `TaskCompletionSource` bridges the STA thread back to async callers. +- `Application.DoEvents()` pumps COM messages between work items (required for MxAccess callbacks like OnDataChange). +- `RunContinuationsAsynchronously` prevents continuations from running on the STA thread. +- On dispose, `CompleteAdding()` signals the loop to exit, then `Join(10s)` waits for drain. + +**Dependency**: The Host project already references `System.Windows.Forms` implicitly through .NET Framework 4.8. If the build fails with a missing reference, add `` to the csproj ``. + +--- + +## Step 2: MxAccessClient — Connection + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` (main partial class) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs` (connection partial) + +### 2.1 Main class file + +```csharp +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + /// + /// Wraps the ArchestrA MXAccess COM API. All COM operations + /// execute on a dedicated STA thread via . + /// + public sealed partial class MxAccessClient : IScadaClient + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly StaDispatchThread _staThread; + private readonly object _lock = new object(); + private readonly int _maxConcurrentOperations; + private readonly int _readTimeoutMs; + private readonly int _writeTimeoutMs; + private readonly int _monitorIntervalMs; + private readonly bool _autoReconnect; + private readonly string? _nodeName; + private readonly string? _galaxyName; + + private readonly SemaphoreSlim _readSemaphore; + private readonly SemaphoreSlim _writeSemaphore; + + // COM objects — only accessed on STA thread + private ArchestrA.MxAccess.LMXProxyServerClass? _lmxProxy; + private int _connectionHandle; + + // State + private ConnectionState _connectionState = ConnectionState.Disconnected; + private DateTime _connectedSince; + private bool _disposed; + + // Reconnect + private CancellationTokenSource? _reconnectCts; + + // Stored subscriptions for reconnect replay + private readonly Dictionary> _storedSubscriptions + = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public MxAccessClient( + int maxConcurrentOperations = 10, + int readTimeoutSeconds = 5, + int writeTimeoutSeconds = 5, + int monitorIntervalSeconds = 5, + bool autoReconnect = true, + string? nodeName = null, + string? galaxyName = null) + { + _maxConcurrentOperations = maxConcurrentOperations; + _readTimeoutMs = readTimeoutSeconds * 1000; + _writeTimeoutMs = writeTimeoutSeconds * 1000; + _monitorIntervalMs = monitorIntervalSeconds * 1000; + _autoReconnect = autoReconnect; + _nodeName = nodeName; + _galaxyName = galaxyName; + + _readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); + _writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); + _staThread = new StaDispatchThread(); + } + + public bool IsConnected + { + get + { + lock (_lock) + { + return _lmxProxy != null + && _connectionState == ConnectionState.Connected + && _connectionHandle > 0; + } + } + } + + public ConnectionState ConnectionState + { + get { lock (_lock) { return _connectionState; } } + } + + public event EventHandler? ConnectionStateChanged; + + private void SetState(ConnectionState newState, string? message = null) + { + ConnectionState previousState; + lock (_lock) + { + previousState = _connectionState; + _connectionState = newState; + } + + if (previousState != newState) + { + Log.Information("Connection state changed: {Previous} -> {Current} {Message}", + previousState, newState, message ?? ""); + ConnectionStateChanged?.Invoke(this, + new ConnectionStateChangedEventArgs(previousState, newState, message)); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + _reconnectCts?.Cancel(); + + try + { + await DisconnectAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error during disposal disconnect"); + } + + _readSemaphore.Dispose(); + _writeSemaphore.Dispose(); + _staThread.Dispose(); + _reconnectCts?.Dispose(); + } + } +} +``` + +### 2.2 Connection partial class + +```csharp +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + public sealed partial class MxAccessClient + { + /// + /// Connects to MxAccess on the STA thread. + /// + public async Task ConnectAsync(CancellationToken ct = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient)); + if (IsConnected) return; + + SetState(ConnectionState.Connecting); + + try + { + await _staThread.DispatchAsync(() => + { + // Create COM object + _lmxProxy = new ArchestrA.MxAccess.LMXProxyServerClass(); + + // Wire event handlers + _lmxProxy.DataChanged += OnDataChange; + _lmxProxy.WriteCompleted += OnWriteComplete; + + // Register with MxAccess + _connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host"); + }); + + lock (_lock) + { + _connectedSince = DateTime.UtcNow; + } + + SetState(ConnectionState.Connected); + Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle); + + // Recreate any stored subscriptions from a previous connection + await RecreateStoredSubscriptionsAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to connect to MxAccess"); + await CleanupComObjectsAsync(); + SetState(ConnectionState.Error, ex.Message); + throw; + } + } + + /// + /// Disconnects from MxAccess on the STA thread. + /// + public async Task DisconnectAsync(CancellationToken ct = default) + { + if (!IsConnected) return; + + SetState(ConnectionState.Disconnecting); + + try + { + await _staThread.DispatchAsync(() => + { + if (_lmxProxy != null && _connectionHandle > 0) + { + try + { + // Remove event handlers first + _lmxProxy.DataChanged -= OnDataChange; + _lmxProxy.WriteCompleted -= OnWriteComplete; + + // Unregister + _lmxProxy.Unregister(_connectionHandle); + } + catch (Exception ex) + { + Log.Warning(ex, "Error during MxAccess unregister"); + } + finally + { + // Force-release COM object + Marshal.ReleaseComObject(_lmxProxy); + _lmxProxy = null; + _connectionHandle = 0; + } + } + }); + + SetState(ConnectionState.Disconnected); + Log.Information("Disconnected from MxAccess"); + } + catch (Exception ex) + { + Log.Error(ex, "Error during disconnect"); + SetState(ConnectionState.Error, ex.Message); + } + } + + /// + /// Starts the auto-reconnect monitor loop. + /// Call this after initial ConnectAsync succeeds. + /// + public void StartMonitorLoop() + { + if (!_autoReconnect) return; + + _reconnectCts = new CancellationTokenSource(); + Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token)); + } + + /// + /// Stops the auto-reconnect monitor loop. + /// Waits up to 5 seconds for the loop to exit. + /// + public void StopMonitorLoop() + { + _reconnectCts?.Cancel(); + } + + /// + /// Auto-reconnect monitor loop. Checks connection every monitorInterval. + /// On disconnect, attempts reconnect. On failure, retries at next interval. + /// + private async Task MonitorConnectionAsync(CancellationToken ct) + { + Log.Information("Connection monitor loop started (interval={IntervalMs}ms)", _monitorIntervalMs); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(_monitorIntervalMs, ct); + } + catch (OperationCanceledException) + { + break; + } + + if (IsConnected) continue; + + Log.Information("MxAccess disconnected, attempting reconnect..."); + SetState(ConnectionState.Reconnecting); + + try + { + await ConnectAsync(ct); + Log.Information("Reconnected to MxAccess successfully"); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + Log.Warning(ex, "Reconnect attempt failed, will retry in {IntervalMs}ms", _monitorIntervalMs); + } + } + + Log.Information("Connection monitor loop exited"); + } + + /// + /// Cleans up COM objects on the STA thread after a failed connection. + /// + private async Task CleanupComObjectsAsync() + { + try + { + await _staThread.DispatchAsync(() => + { + if (_lmxProxy != null) + { + try { _lmxProxy.DataChanged -= OnDataChange; } catch { } + try { _lmxProxy.WriteCompleted -= OnWriteComplete; } catch { } + try { Marshal.ReleaseComObject(_lmxProxy); } catch { } + _lmxProxy = null; + } + _connectionHandle = 0; + }); + } + catch (Exception ex) + { + Log.Warning(ex, "Error during COM object cleanup"); + } + } + + /// Gets the UTC time when the connection was established. + public DateTime ConnectedSince + { + get { lock (_lock) { return _connectedSince; } } + } + } +} +``` + +**Note**: The exact COM interop method names (`Register`, `Unregister`, `DataChanged`, `WriteCompleted`) come from the ArchestrA.MXAccess COM interop assembly. Consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs` for the exact method signatures and event wiring patterns. The reference code uses `_lmxProxy.DataChanged += OnDataChange` style — match that exactly. + +--- + +## Step 3: MxAccessClient — Read/Write + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + public sealed partial class MxAccessClient + { + /// + /// Reads a single tag value from MxAccess. + /// Dispatched to STA thread with semaphore concurrency control. + /// + public async Task ReadAsync(string address, CancellationToken ct = default) + { + if (!IsConnected) + return Vtq.New(null, Quality.Bad_NotConnected); + + await _readSemaphore.WaitAsync(ct); + try + { + return await _staThread.DispatchAsync(() => ReadInternal(address)); + } + catch (Exception ex) + { + Log.Error(ex, "ReadAsync failed for tag {Address}", address); + return Vtq.New(null, Quality.Bad_CommFailure); + } + finally + { + _readSemaphore.Release(); + } + } + + /// + /// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent). + /// Each tag is read independently. Partial failures return Bad quality for failed tags. + /// + public async Task> ReadBatchAsync( + IEnumerable addresses, CancellationToken ct = default) + { + var addressList = addresses.ToList(); + var results = new Dictionary(addressList.Count, StringComparer.OrdinalIgnoreCase); + + var tasks = addressList.Select(async address => + { + var vtq = await ReadAsync(address, ct); + return (address, vtq); + }); + + foreach (var task in await Task.WhenAll(tasks)) + { + results[task.address] = task.vtq; + } + + return results; + } + + /// + /// Writes a single tag value to MxAccess. + /// Value should be a native .NET type (not string). Uses TypedValueConverter + /// on the gRPC layer; here the value is the boxed .NET object. + /// + public async Task WriteAsync(string address, object value, CancellationToken ct = default) + { + if (!IsConnected) + throw new InvalidOperationException("Not connected to MxAccess"); + + await _writeSemaphore.WaitAsync(ct); + try + { + await _staThread.DispatchAsync(() => WriteInternal(address, value)); + } + finally + { + _writeSemaphore.Release(); + } + } + + /// + /// Writes multiple tag values with semaphore-controlled concurrency. + /// + public async Task WriteBatchAsync( + IReadOnlyDictionary values, CancellationToken ct = default) + { + var tasks = values.Select(async kvp => + { + await WriteAsync(kvp.Key, kvp.Value, ct); + }); + + await Task.WhenAll(tasks); + } + + /// + /// Writes a batch, then polls flagTag until it equals flagValue or timeout expires. + /// Uses type-aware comparison via TypedValueEquals. + /// + public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, + string flagTag, + object flagValue, + int timeoutMs, + int pollIntervalMs, + CancellationToken ct = default) + { + // Write all values first + await WriteBatchAsync(values, ct); + + // Poll flag tag + var sw = System.Diagnostics.Stopwatch.StartNew(); + var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000; + var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100; + + while (sw.ElapsedMilliseconds < effectiveTimeout) + { + ct.ThrowIfCancellationRequested(); + + var vtq = await ReadAsync(flagTag, ct); + if (vtq.Quality.IsGood() && TypedValueEquals(vtq.Value, flagValue)) + { + return (true, (int)sw.ElapsedMilliseconds); + } + + await Task.Delay(effectiveInterval, ct); + } + + return (false, (int)sw.ElapsedMilliseconds); + } + + /// + /// Type-aware equality comparison for WriteBatchAndWait flag matching. + /// Both values must be the same CLR type. Mismatched types are never equal. + /// + private static bool TypedValueEquals(object? a, object? b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (a.GetType() != b.GetType()) return false; + + // Array types need element-by-element comparison + if (a is Array arrA && b is Array arrB) + { + if (arrA.Length != arrB.Length) return false; + for (int i = 0; i < arrA.Length; i++) + { + if (!Equals(arrA.GetValue(i), arrB.GetValue(i))) + return false; + } + return true; + } + + return Equals(a, b); + } + + // ── Internal COM calls (execute on STA thread) ────────── + + /// + /// Reads a single tag from MxAccess COM API. + /// Must be called on the STA thread. + /// + private Vtq ReadInternal(string address) + { + // This is a skeleton — the exact MxAccess COM API call depends on the + // ArchestrA.MXAccess interop assembly. Consult src-reference for the exact + // method calls. The pattern is: + // + // object value = null; + // int quality = 0; + // DateTime timestamp = DateTime.MinValue; + // _lmxProxy.Read(_connectionHandle, address, ref value, ref quality, ref timestamp); + // + // Then convert the COM value to a Vtq: + // return new Vtq(value, timestamp.ToUniversalTime(), MapQuality(quality)); + // + // For now, this throws NotImplementedException. The actual COM call will be + // implemented when testing on the windev machine with MxAccess available. + + throw new NotImplementedException( + "ReadInternal must be implemented using ArchestrA.MXAccess COM API. " + + "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); + } + + /// + /// Writes a single tag via MxAccess COM API. + /// Must be called on the STA thread. + /// + private void WriteInternal(string address, object value) + { + // Similar to ReadInternal — the exact COM call is: + // _lmxProxy.Write(_connectionHandle, address, value); + // + // Consult src-reference for the exact method signature. + + throw new NotImplementedException( + "WriteInternal must be implemented using ArchestrA.MXAccess COM API. " + + "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); + } + + /// + /// Maps an MxAccess OPC DA quality integer to the domain Quality enum. + /// The quality integer from MxAccess is the OPC DA quality byte. + /// + private static Quality MapQuality(int opcDaQuality) + { + // OPC DA quality is a byte value that directly maps to our Quality enum + if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality)) + return (Quality)(byte)opcDaQuality; + + // Fallback: use category bits + if (opcDaQuality >= 192) return Quality.Good; + if (opcDaQuality >= 64) return Quality.Uncertain; + return Quality.Bad; + } + } +} +``` + +**Important note on ReadInternal/WriteInternal**: These methods contain `throw new NotImplementedException()` because the exact MxAccess COM API signatures depend on the ArchestrA.MXAccess interop assembly, which is only available on the Windows development machine. The implementing session should: + +1. Read `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs` to find the exact COM method signatures. +2. Replace the `throw` with the actual COM calls. +3. The pattern is well-established in the reference code — it's a direct translation, not a redesign. + +--- + +## Step 4: MxAccessClient — Subscriptions + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + public sealed partial class MxAccessClient + { + /// + /// Subscribes to value changes for the specified addresses. + /// Stores subscription state for reconnect replay. + /// + public async Task SubscribeAsync( + IEnumerable addresses, + Action callback, + CancellationToken ct = default) + { + if (!IsConnected) + throw new InvalidOperationException("Not connected to MxAccess"); + + var addressList = addresses.ToList(); + + await _staThread.DispatchAsync(() => + { + foreach (var address in addressList) + { + SubscribeInternal(address); + + // Store for reconnect replay + lock (_lock) + { + _storedSubscriptions[address] = callback; + } + } + }); + + Log.Information("Subscribed to {Count} tags", addressList.Count); + + return new SubscriptionHandle(this, addressList, callback); + } + + /// + /// Unsubscribes specific addresses. + /// + internal async Task UnsubscribeAsync(IEnumerable addresses) + { + var addressList = addresses.ToList(); + + await _staThread.DispatchAsync(() => + { + foreach (var address in addressList) + { + UnsubscribeInternal(address); + + lock (_lock) + { + _storedSubscriptions.Remove(address); + } + } + }); + + Log.Information("Unsubscribed from {Count} tags", addressList.Count); + } + + /// + /// Recreates all stored subscriptions after a reconnect. + /// Does not re-store them (they're already stored). + /// + private async Task RecreateStoredSubscriptionsAsync() + { + Dictionary> subscriptions; + lock (_lock) + { + if (_storedSubscriptions.Count == 0) return; + subscriptions = new Dictionary>(_storedSubscriptions); + } + + Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count); + + await _staThread.DispatchAsync(() => + { + foreach (var kvp in subscriptions) + { + try + { + SubscribeInternal(kvp.Key); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key); + } + } + }); + } + + // ── Internal COM calls (execute on STA thread) ────────── + + /// + /// Registers a tag subscription with MxAccess COM API (Advise). + /// Must be called on the STA thread. + /// + private void SubscribeInternal(string address) + { + // The exact MxAccess COM API call is something like: + // _lmxProxy.Advise(_connectionHandle, address); + // + // Consult src-reference/Implementation/MxAccessClient.Subscription.cs + + throw new NotImplementedException( + "SubscribeInternal must be implemented using ArchestrA.MXAccess COM API. " + + "See src-reference/Implementation/MxAccessClient.Subscription.cs for the exact pattern."); + } + + /// + /// Unregisters a tag subscription from MxAccess COM API (Unadvise). + /// Must be called on the STA thread. + /// + private void UnsubscribeInternal(string address) + { + // The exact MxAccess COM API call is something like: + // _lmxProxy.Unadvise(_connectionHandle, address); + + throw new NotImplementedException( + "UnsubscribeInternal must be implemented using ArchestrA.MXAccess COM API."); + } + + /// + /// Disposable subscription handle that unsubscribes on disposal. + /// + private sealed class SubscriptionHandle : IAsyncDisposable + { + private readonly MxAccessClient _client; + private readonly List _addresses; + private readonly Action _callback; + private bool _disposed; + + public SubscriptionHandle(MxAccessClient client, List addresses, Action callback) + { + _client = client; + _addresses = addresses; + _callback = callback; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + await _client.UnsubscribeAsync(_addresses); + } + } + } +} +``` + +--- + +## Step 5: MxAccessClient — Event Handlers + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs` + +```csharp +using System; +using System.Collections.Generic; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.MxAccess +{ + public sealed partial class MxAccessClient + { + /// + /// Callback invoked by the SubscriptionManager when it needs to deliver + /// data change events. Set by the SubscriptionManager during initialization. + /// + public Action? OnTagValueChanged { get; set; } + + /// + /// COM event handler for MxAccess DataChanged events. + /// Called on the STA thread when a subscribed tag value changes. + /// + private void OnDataChange( + int hConnect, + int numberOfItems, + // The exact parameter types depend on the COM interop assembly. + // Consult src-reference/Implementation/MxAccessClient.EventHandlers.cs + // for the exact signature. The pattern is: + // object[] addresses, object[] values, object[] qualities, object[] timestamps + // or it may use SAFEARRAY parameters. + object addresses, + object values, + object qualities, + object timestamps) + { + // This handler fires on the STA thread. + // Parse the COM arrays and dispatch to OnTagValueChanged for each item. + // + // Skeleton implementation: + try + { + var addrArray = (object[])addresses; + var valArray = (object[])values; + var qualArray = (object[])qualities; + var tsArray = (object[])timestamps; + + for (int i = 0; i < numberOfItems; i++) + { + var address = addrArray[i]?.ToString() ?? ""; + var value = valArray[i]; + var quality = MapQuality(Convert.ToInt32(qualArray[i])); + var timestamp = Convert.ToDateTime(tsArray[i]).ToUniversalTime(); + + var vtq = new Vtq(value, timestamp, quality); + + // Route to stored callback + Action? callback = null; + lock (_lock) + { + _storedSubscriptions.TryGetValue(address, out callback); + } + callback?.Invoke(address, vtq); + + // Also route to the SubscriptionManager's global handler + OnTagValueChanged?.Invoke(address, vtq); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error processing OnDataChange event"); + } + } + + /// + /// COM event handler for MxAccess WriteCompleted events. + /// + private void OnWriteComplete( + int hConnect, + int numberOfItems, + object addresses, + object results) + { + // Write completion is currently fire-and-forget. + // Log for diagnostics. + try + { + Log.Debug("WriteCompleted: {Count} items", numberOfItems); + } + catch (Exception ex) + { + Log.Error(ex, "Error processing OnWriteComplete event"); + } + } + } +} +``` + +**Important**: The exact COM event handler signatures (`OnDataChange`, `OnWriteComplete`) depend on the ArchestrA.MXAccess COM interop assembly's event definitions. The implementing session MUST consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs` for the exact parameter types. The skeleton above uses a common pattern but may need adjustment. + +--- + +## Step 6: SessionManager + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` + +```csharp +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Sessions +{ + /// + /// Tracks active client sessions in memory. + /// Thread-safe via ConcurrentDictionary. + /// + public sealed class SessionManager : IDisposable + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly ConcurrentDictionary _sessions + = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly Timer? _scavengingTimer; + private readonly TimeSpan _inactivityTimeout; + + /// + /// Creates a SessionManager with optional inactivity scavenging. + /// + /// + /// Sessions inactive for this many minutes are automatically terminated. + /// Set to 0 to disable scavenging. + /// + public SessionManager(int inactivityTimeoutMinutes = 5) + { + _inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes); + + if (inactivityTimeoutMinutes > 0) + { + // Check every 60 seconds + _scavengingTimer = new Timer(ScavengeInactiveSessions, null, + TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + } + } + + /// Gets the count of active sessions. + public int ActiveSessionCount => _sessions.Count; + + /// + /// Creates a new session. + /// Returns the 32-character hex GUID session ID. + /// + public string CreateSession(string clientId, string apiKey) + { + var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens + var sessionInfo = new SessionInfo(sessionId, clientId, apiKey); + _sessions[sessionId] = sessionInfo; + + Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId); + return sessionId; + } + + /// + /// Validates a session ID. Updates LastActivity on success. + /// Returns true if the session exists. + /// + public bool ValidateSession(string sessionId) + { + if (_sessions.TryGetValue(sessionId, out var session)) + { + session.TouchLastActivity(); + return true; + } + return false; + } + + /// + /// Terminates a session. Returns true if the session existed. + /// + public bool TerminateSession(string sessionId) + { + if (_sessions.TryRemove(sessionId, out _)) + { + Log.Information("Session terminated: {SessionId}", sessionId); + return true; + } + return false; + } + + /// Gets session info by ID, or null if not found. + public SessionInfo? GetSession(string sessionId) + { + _sessions.TryGetValue(sessionId, out var session); + return session; + } + + /// Gets a snapshot of all active sessions. + public IReadOnlyList GetAllSessions() + { + return _sessions.Values.ToList().AsReadOnly(); + } + + /// + /// Scavenges sessions that have been inactive for longer than the timeout. + /// + private void ScavengeInactiveSessions(object? state) + { + if (_inactivityTimeout <= TimeSpan.Zero) return; + + var cutoff = DateTime.UtcNow - _inactivityTimeout; + var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList(); + + foreach (var kvp in expired) + { + if (_sessions.TryRemove(kvp.Key, out _)) + { + Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", + kvp.Key, kvp.Value.LastActivity); + } + } + } + + public void Dispose() + { + _scavengingTimer?.Dispose(); + _sessions.Clear(); + } + } + + /// + /// Information about an active client session. + /// + public class SessionInfo + { + public SessionInfo(string sessionId, string clientId, string apiKey) + { + SessionId = sessionId; + ClientId = clientId; + ApiKey = apiKey; + ConnectedAt = DateTime.UtcNow; + LastActivity = DateTime.UtcNow; + } + + public string SessionId { get; } + public string ClientId { get; } + public string ApiKey { get; } + public DateTime ConnectedAt { get; } + public DateTime LastActivity { get; private set; } + public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; + + /// Updates the last activity timestamp to now. + public void TouchLastActivity() + { + LastActivity = DateTime.UtcNow; + } + } +} +``` + +--- + +## Step 7: SubscriptionManager + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` + +```csharp +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions +{ + /// + /// Manages per-client subscription channels with shared MxAccess subscriptions. + /// Ref-counted tag subscriptions: first client creates, last client disposes. + /// + public sealed class SubscriptionManager : IDisposable + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly IScadaClient _scadaClient; + private readonly int _channelCapacity; + private readonly BoundedChannelFullMode _channelFullMode; + + // Client ID → ClientSubscription + private readonly ConcurrentDictionary _clientSubscriptions + = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + // Tag address → TagSubscription (shared, ref-counted) + private readonly ConcurrentDictionary _tagSubscriptions + = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim(); + + public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000, + BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest) + { + _scadaClient = scadaClient; + _channelCapacity = channelCapacity; + _channelFullMode = channelFullMode; + } + + /// + /// Creates a subscription for a client. Returns a ChannelReader to stream from. + /// + public ChannelReader<(string address, Vtq vtq)> Subscribe( + string clientId, IEnumerable addresses, CancellationToken ct) + { + var channel = Channel.CreateBounded<(string address, Vtq vtq)>( + new BoundedChannelOptions(_channelCapacity) + { + FullMode = _channelFullMode, + SingleReader = true, + SingleWriter = false + }); + + var addressSet = new HashSet(addresses, StringComparer.OrdinalIgnoreCase); + var clientSub = new ClientSubscription(clientId, channel, addressSet); + + _clientSubscriptions[clientId] = clientSub; + + _rwLock.EnterWriteLock(); + try + { + foreach (var address in addressSet) + { + if (_tagSubscriptions.TryGetValue(address, out var tagSub)) + { + tagSub.ClientIds.Add(clientId); + } + else + { + _tagSubscriptions[address] = new TagSubscription(address, + new HashSet(StringComparer.OrdinalIgnoreCase) { clientId }); + } + } + } + finally + { + _rwLock.ExitWriteLock(); + } + + // Register cancellation cleanup + ct.Register(() => UnsubscribeClient(clientId)); + + Log.Information("Client {ClientId} subscribed to {Count} tags", clientId, addressSet.Count); + return channel.Reader; + } + + /// + /// Called from MxAccessClient's OnDataChange handler. + /// Fans out the update to all subscribed clients. + /// + public void OnTagValueChanged(string address, Vtq vtq) + { + _rwLock.EnterReadLock(); + HashSet? clientIds = null; + try + { + if (_tagSubscriptions.TryGetValue(address, out var tagSub)) + { + clientIds = new HashSet(tagSub.ClientIds); + } + } + finally + { + _rwLock.ExitReadLock(); + } + + if (clientIds == null || clientIds.Count == 0) return; + + foreach (var clientId in clientIds) + { + if (_clientSubscriptions.TryGetValue(clientId, out var clientSub)) + { + if (!clientSub.Channel.Writer.TryWrite((address, vtq))) + { + clientSub.IncrementDropped(); + Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)", + clientId, address); + } + else + { + clientSub.IncrementDelivered(); + } + } + } + } + + /// + /// Removes a client's subscriptions and cleans up tag subscriptions + /// when the last client unsubscribes. + /// + public void UnsubscribeClient(string clientId) + { + if (!_clientSubscriptions.TryRemove(clientId, out var clientSub)) + return; + + _rwLock.EnterWriteLock(); + try + { + foreach (var address in clientSub.Addresses) + { + if (_tagSubscriptions.TryGetValue(address, out var tagSub)) + { + tagSub.ClientIds.Remove(clientId); + + // Last client unsubscribed — remove the tag subscription + if (tagSub.ClientIds.Count == 0) + { + _tagSubscriptions.TryRemove(address, out _); + } + } + } + } + finally + { + _rwLock.ExitWriteLock(); + } + + // Complete the channel (signals end of stream to the gRPC handler) + clientSub.Channel.Writer.TryComplete(); + + Log.Information("Client {ClientId} unsubscribed ({Delivered} delivered, {Dropped} dropped)", + clientId, clientSub.DeliveredCount, clientSub.DroppedCount); + } + + /// + /// Sends a bad-quality notification to all subscribed clients for all their tags. + /// Called when MxAccess disconnects. + /// + public void NotifyDisconnection() + { + var badVtq = Vtq.New(null, Quality.Bad_NotConnected); + + foreach (var kvp in _clientSubscriptions) + { + foreach (var address in kvp.Value.Addresses) + { + kvp.Value.Channel.Writer.TryWrite((address, badVtq)); + } + } + } + + /// Returns subscription statistics. + public SubscriptionStats GetStats() + { + return new SubscriptionStats( + _clientSubscriptions.Count, + _tagSubscriptions.Count, + _clientSubscriptions.Values.Sum(c => c.Addresses.Count)); + } + + public void Dispose() + { + foreach (var kvp in _clientSubscriptions) + { + kvp.Value.Channel.Writer.TryComplete(); + } + _clientSubscriptions.Clear(); + _tagSubscriptions.Clear(); + _rwLock.Dispose(); + } + + // ── Nested types ───────────────────────────────────────── + + private class ClientSubscription + { + public ClientSubscription(string clientId, + Channel<(string address, Vtq vtq)> channel, + HashSet addresses) + { + ClientId = clientId; + Channel = channel; + Addresses = addresses; + } + + public string ClientId { get; } + public Channel<(string address, Vtq vtq)> Channel { get; } + public HashSet Addresses { get; } + public long DeliveredCount { get; private set; } + public long DroppedCount { get; private set; } + + public void IncrementDelivered() => Interlocked.Increment(ref _delivered); + public void IncrementDropped() => Interlocked.Increment(ref _dropped); + + // Use backing fields for Interlocked + private long _delivered; + private long _dropped; + } + + private class TagSubscription + { + public TagSubscription(string address, HashSet clientIds) + { + Address = address; + ClientIds = clientIds; + } + + public string Address { get; } + public HashSet ClientIds { get; } + } + } +} +``` + +**Note**: The `ClientSubscription` class has a minor issue — `DeliveredCount` and `DroppedCount` properties read the old field values, not the `_delivered`/`_dropped` backing fields. Fix by changing the properties to: + +```csharp +public long DeliveredCount => Interlocked.Read(ref _delivered); +public long DroppedCount => Interlocked.Read(ref _dropped); +``` + +Also need to add `SubscriptionStats` to the Domain: + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// Subscription statistics for monitoring. + public class SubscriptionStats + { + public SubscriptionStats(int totalClients, int totalTags, int activeSubscriptions) + { + TotalClients = totalClients; + TotalTags = totalTags; + ActiveSubscriptions = activeSubscriptions; + } + + public int TotalClients { get; } + public int TotalTags { get; } + public int ActiveSubscriptions { get; } + } +} +``` + +--- + +## Step 8: Unit tests + +### 8.1 SessionManager tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs` + +```csharp +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Sessions; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions +{ + public class SessionManagerTests + { + [Fact] + public void CreateSession_Returns32CharHexId() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("client1", "key1"); + id.Should().HaveLength(32); + id.Should().MatchRegex("^[0-9a-f]{32}$"); + } + + [Fact] + public void CreateSession_IncrementsCount() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.ActiveSessionCount.Should().Be(0); + sm.CreateSession("c1", "k1"); + sm.ActiveSessionCount.Should().Be(1); + sm.CreateSession("c2", "k2"); + sm.ActiveSessionCount.Should().Be(2); + } + + [Fact] + public void ValidateSession_ReturnsTrueForExistingSession() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("c1", "k1"); + sm.ValidateSession(id).Should().BeTrue(); + } + + [Fact] + public void ValidateSession_ReturnsFalseForUnknownSession() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.ValidateSession("nonexistent").Should().BeFalse(); + } + + [Fact] + public void ValidateSession_UpdatesLastActivity() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("c1", "k1"); + var session = sm.GetSession(id); + var initialActivity = session!.LastActivity; + + Thread.Sleep(50); // Small delay to ensure time passes + sm.ValidateSession(id); + + session.LastActivity.Should().BeAfter(initialActivity); + } + + [Fact] + public void TerminateSession_RemovesSession() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("c1", "k1"); + sm.TerminateSession(id).Should().BeTrue(); + sm.ActiveSessionCount.Should().Be(0); + sm.ValidateSession(id).Should().BeFalse(); + } + + [Fact] + public void TerminateSession_ReturnsFalseForUnknownSession() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.TerminateSession("nonexistent").Should().BeFalse(); + } + + [Fact] + public void GetSession_ReturnsNullForUnknown() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.GetSession("nonexistent").Should().BeNull(); + } + + [Fact] + public void GetSession_ReturnsCorrectInfo() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("client-abc", "key-xyz"); + var session = sm.GetSession(id); + session.Should().NotBeNull(); + session!.ClientId.Should().Be("client-abc"); + session.ApiKey.Should().Be("key-xyz"); + session.SessionId.Should().Be(id); + session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); + } + + [Fact] + public void GetAllSessions_ReturnsSnapshot() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.CreateSession("c1", "k1"); + sm.CreateSession("c2", "k2"); + var all = sm.GetAllSessions(); + all.Should().HaveCount(2); + } + + [Fact] + public void ConcurrentAccess_IsThreadSafe() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var tasks = new Task[100]; + for (int i = 0; i < 100; i++) + { + int idx = i; + tasks[i] = Task.Run(() => + { + var id = sm.CreateSession($"client-{idx}", $"key-{idx}"); + sm.ValidateSession(id); + if (idx % 3 == 0) sm.TerminateSession(id); + }); + } + Task.WaitAll(tasks); + + // Should have ~67 sessions remaining (100 - ~33 terminated) + sm.ActiveSessionCount.Should().BeInRange(60, 70); + } + + [Fact] + public void Dispose_ClearsAllSessions() + { + var sm = new SessionManager(inactivityTimeoutMinutes: 0); + sm.CreateSession("c1", "k1"); + sm.CreateSession("c2", "k2"); + sm.Dispose(); + sm.ActiveSessionCount.Should().Be(0); + } + + [Fact] + public void ConnectedSinceUtcTicks_ReturnsCorrectValue() + { + using var sm = new SessionManager(inactivityTimeoutMinutes: 0); + var id = sm.CreateSession("c1", "k1"); + var session = sm.GetSession(id); + session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks); + } + } +} +``` + +### 8.2 SubscriptionManager tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; +using ZB.MOM.WW.LmxProxy.Host.Subscriptions; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions +{ + public class SubscriptionManagerTests + { + /// Fake IScadaClient for testing (no COM dependency). + private class FakeScadaClient : IScadaClient + { + public bool IsConnected => true; + public ConnectionState ConnectionState => ConnectionState.Connected; + public event EventHandler? ConnectionStateChanged; + public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task ReadAsync(string address, CancellationToken ct = default) => + Task.FromResult(Vtq.Good(42.0)); + public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => + Task.FromResult>(new Dictionary()); + public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; + public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; + public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagTag, object flagValue, + int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => + Task.FromResult((false, 0)); + public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => + Task.FromResult(new FakeSubscriptionHandle()); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + private class FakeSubscriptionHandle : IAsyncDisposable { public ValueTask DisposeAsync() => ValueTask.CompletedTask; } + } + + [Fact] + public void Subscribe_ReturnsChannelReader() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Tag1", "Tag2" }, cts.Token); + reader.Should().NotBeNull(); + } + + [Fact] + public async Task OnTagValueChanged_FansOutToSubscribedClients() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + + var vtq = Vtq.Good(42.0); + sm.OnTagValueChanged("Motor.Speed", vtq); + + var result = await reader.ReadAsync(cts.Token); + result.address.Should().Be("Motor.Speed"); + result.vtq.Value.Should().Be(42.0); + result.vtq.Quality.Should().Be(Quality.Good); + } + + [Fact] + public async Task OnTagValueChanged_MultipleClients_BothReceive() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader1 = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + var reader2 = sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); + + sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0)); + + var r1 = await reader1.ReadAsync(cts.Token); + var r2 = await reader2.ReadAsync(cts.Token); + r1.vtq.Value.Should().Be(99.0); + r2.vtq.Value.Should().Be(99.0); + } + + [Fact] + public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + + sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0)); + + // Channel should be empty + reader.TryRead(out _).Should().BeFalse(); + } + + [Fact] + public void UnsubscribeClient_CompletesChannel() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + + sm.UnsubscribeClient("client1"); + + // Channel should be completed + reader.Completion.IsCompleted.Should().BeTrue(); + } + + [Fact] + public void UnsubscribeClient_RemovesFromTagSubscriptions() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + + sm.UnsubscribeClient("client1"); + + var stats = sm.GetStats(); + stats.TotalClients.Should().Be(0); + stats.TotalTags.Should().Be(0); + } + + [Fact] + public void RefCounting_LastClientUnsubscribeRemovesTag() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); + + sm.GetStats().TotalTags.Should().Be(1); + + sm.UnsubscribeClient("client1"); + sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed + + sm.UnsubscribeClient("client2"); + sm.GetStats().TotalTags.Should().Be(0); // last client gone + } + + [Fact] + public void NotifyDisconnection_SendsBadQualityToAll() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token); + + sm.NotifyDisconnection(); + + // Should receive 2 bad quality messages + reader.TryRead(out var r1).Should().BeTrue(); + r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected); + reader.TryRead(out var r2).Should().BeTrue(); + r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected); + } + + [Fact] + public void Backpressure_DropOldest_DropsWhenFull() + { + using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3); + using var cts = new CancellationTokenSource(); + var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); + + // Fill the channel beyond capacity + for (int i = 0; i < 10; i++) + { + sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i)); + } + + // Should have exactly 3 messages (capacity limit) + int count = 0; + while (reader.TryRead(out _)) count++; + count.Should().Be(3); + } + + [Fact] + public void GetStats_ReturnsCorrectCounts() + { + using var sm = new SubscriptionManager(new FakeScadaClient()); + using var cts = new CancellationTokenSource(); + sm.Subscribe("c1", new[] { "Tag1", "Tag2" }, cts.Token); + sm.Subscribe("c2", new[] { "Tag2", "Tag3" }, cts.Token); + + var stats = sm.GetStats(); + stats.TotalClients.Should().Be(2); + stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3 + stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3 + } + } +} +``` + +### 8.3 StaDispatchThread tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/StaDispatchThreadTests.cs` + +```csharp +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.MxAccess; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess +{ + public class StaDispatchThreadTests + { + [Fact] + public async Task DispatchAsync_ExecutesOnStaThread() + { + using var sta = new StaDispatchThread("Test-STA"); + var threadId = await sta.DispatchAsync(() => Thread.CurrentThread.ManagedThreadId); + threadId.Should().NotBe(Thread.CurrentThread.ManagedThreadId); + } + + [Fact] + public async Task DispatchAsync_ReturnsResult() + { + using var sta = new StaDispatchThread("Test-STA"); + var result = await sta.DispatchAsync(() => 42); + result.Should().Be(42); + } + + [Fact] + public async Task DispatchAsync_PropagatesException() + { + using var sta = new StaDispatchThread("Test-STA"); + var act = () => sta.DispatchAsync(() => throw new InvalidOperationException("test error")); + await act.Should().ThrowAsync().WithMessage("test error"); + } + + [Fact] + public async Task DispatchAsync_Action_Completes() + { + using var sta = new StaDispatchThread("Test-STA"); + int value = 0; + await sta.DispatchAsync(() => { value = 99; }); + value.Should().Be(99); + } + + [Fact] + public void Dispose_CompletesGracefully() + { + var sta = new StaDispatchThread("Test-STA"); + sta.Dispose(); // Should not throw + } + + [Fact] + public void DispatchAfterDispose_ThrowsObjectDisposedException() + { + var sta = new StaDispatchThread("Test-STA"); + sta.Dispose(); + var act = () => sta.DispatchAsync(() => 42); + act.Should().ThrowAsync(); + } + + [Fact] + public async Task MultipleDispatches_ExecuteInOrder() + { + using var sta = new StaDispatchThread("Test-STA"); + var results = new System.Collections.Concurrent.ConcurrentBag(); + + var tasks = new Task[10]; + for (int i = 0; i < 10; i++) + { + int idx = i; + tasks[i] = sta.DispatchAsync(() => { results.Add(idx); }); + } + + await Task.WhenAll(tasks); + results.Count.Should().Be(10); + } + + [Fact] + public async Task StaThread_HasStaApartmentState() + { + using var sta = new StaDispatchThread("Test-STA"); + var apartmentState = await sta.DispatchAsync(() => Thread.CurrentThread.GetApartmentState()); + apartmentState.Should().Be(ApartmentState.STA); + } + } +} +``` + +### 8.4 MxAccessClient TypedValueEquals tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs` + +Since `TypedValueEquals` is private in `MxAccessClient`, test it indirectly or extract it to a helper. For testability, create a public static helper: + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs` + +```csharp +using System; + +namespace ZB.MOM.WW.LmxProxy.Host.Domain +{ + /// + /// Type-aware equality comparison for WriteBatchAndWait flag matching. + /// + public static class TypedValueComparer + { + /// + /// Returns true if both values are the same type and equal. + /// Mismatched types are never equal. + /// Null equals null only. + /// + public static bool Equals(object? a, object? b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (a.GetType() != b.GetType()) return false; + + if (a is Array arrA && b is Array arrB) + { + if (arrA.Length != arrB.Length) return false; + for (int i = 0; i < arrA.Length; i++) + { + if (!object.Equals(arrA.GetValue(i), arrB.GetValue(i))) + return false; + } + return true; + } + + return object.Equals(a, b); + } + } +} +``` + +Then the test file: + +```csharp +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Domain; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess +{ + public class TypedValueEqualsTests + { + [Fact] + public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue(); + + [Fact] + public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse(); + + [Fact] + public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse(); + + [Fact] + public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue(); + + [Fact] + public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse(); + + [Fact] + public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); + + [Fact] + public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue(); + + [Fact] + public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse(); + + [Fact] + public void String_CaseSensitive() + { + TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue(); + TypedValueComparer.Equals("done", "DONE").Should().BeFalse(); + } + + [Fact] + public void Array_SameElements() + { + TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue(); + } + + [Fact] + public void Array_DifferentElements() + { + TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse(); + } + + [Fact] + public void Array_DifferentLengths() + { + TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse(); + } + + [Fact] + public void Int32_NotEqual_ToDouble() + { + TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); + } + + [Fact] + public void Long_Equality() + { + TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue(); + } + + [Fact] + public void DateTime_TickPrecision() + { + var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); + var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); + TypedValueComparer.Equals(dt1, dt2).Should().BeTrue(); + } + } +} +``` + +--- + +## Step 9: Build verification + +```bash +cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy + +# Build Client (works on macOS) +dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj + +# Run Client tests +dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj + +# Host builds on Windows only (net48/x86): +# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj +``` + +--- + +## Completion Criteria + +- [ ] `StaDispatchThread` compiles and tests pass (STA apartment, dispatch, exception propagation) +- [ ] `MxAccessClient` main class compiles with all partial files +- [ ] `MxAccessClient.Connection.cs` compiles (ConnectAsync, DisconnectAsync, MonitorConnectionAsync) +- [ ] `MxAccessClient.ReadWrite.cs` compiles (ReadAsync, ReadBatchAsync, WriteAsync, WriteBatchAsync, WriteBatchAndWaitAsync) +- [ ] `MxAccessClient.Subscription.cs` compiles (SubscribeAsync, UnsubscribeAsync, RecreateStoredSubscriptionsAsync) +- [ ] `MxAccessClient.EventHandlers.cs` compiles (OnDataChange, OnWriteComplete) +- [ ] `SessionManager` compiles and all tests pass (CRUD, scavenging, concurrency) +- [ ] `SubscriptionManager` compiles and all tests pass (subscribe, fan-out, unsubscribe, ref-counting, backpressure, disconnect notification) +- [ ] `TypedValueComparer` tests pass (all comparison rules from design doc) +- [ ] COM method bodies are marked with `NotImplementedException` and clear instructions to consult reference code +- [ ] No references to old namespaces diff --git a/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md b/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md new file mode 100644 index 0000000..7b25f97 --- /dev/null +++ b/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md @@ -0,0 +1,1799 @@ +# Phase 3: Host gRPC Server, Security & Configuration — Implementation Plan + +## Prerequisites + +- Phase 1 complete: proto file, domain types, TypedValueConverter, QualityCodeMapper, cross-stack tests passing. +- Phase 2 complete: MxAccessClient, SessionManager, SubscriptionManager, StaDispatchThread compiling and tests passing. +- The following Phase 2 artifacts are used in this phase: + - `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` — `IScadaClient` implementation + - `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` + - `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` + +## Guardrails + +1. **Proto is the source of truth** — all RPC implementations match `scada.proto` exactly. +2. **No v1 code** — no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons. +3. **status_code is canonical** — use `QualityCodeMapper` factory methods for all quality responses. +4. **x-api-key header is authoritative** — interceptor enforces, `ConnectRequest.api_key` is informational only. +5. **TypedValueConverter for all COM↔proto conversions** — no manual type switching in the gRPC service. +6. **Unit tests for every component** before marking phase complete. + +--- + +## Step 1: Configuration classes + +All configuration classes go in `src/ZB.MOM.WW.LmxProxy.Host/Configuration/`. + +### 1.1 LmxProxyConfiguration (root) + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// Root configuration class bound to appsettings.json. + public class LmxProxyConfiguration + { + /// gRPC server listen port. Default: 50051. + public int GrpcPort { get; set; } = 50051; + + /// Path to API key configuration file. Default: apikeys.json. + public string ApiKeyConfigFile { get; set; } = "apikeys.json"; + + /// MxAccess connection settings. + public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration(); + + /// Subscription channel settings. + public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration(); + + /// TLS/SSL settings. + public TlsConfiguration Tls { get; set; } = new TlsConfiguration(); + + /// Status web server settings. + public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration(); + + /// Windows SCM service recovery settings. + public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration(); + } +} +``` + +### 1.2 ConnectionConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// MxAccess connection settings. + public class ConnectionConfiguration + { + /// Auto-reconnect check interval in seconds. Default: 5. + public int MonitorIntervalSeconds { get; set; } = 5; + + /// Initial connection timeout in seconds. Default: 30. + public int ConnectionTimeoutSeconds { get; set; } = 30; + + /// Per-read operation timeout in seconds. Default: 5. + public int ReadTimeoutSeconds { get; set; } = 5; + + /// Per-write operation timeout in seconds. Default: 5. + public int WriteTimeoutSeconds { get; set; } = 5; + + /// Semaphore limit for concurrent MxAccess operations. Default: 10. + public int MaxConcurrentOperations { get; set; } = 10; + + /// Enable auto-reconnect loop. Default: true. + public bool AutoReconnect { get; set; } = true; + + /// MxAccess node name (optional). + public string? NodeName { get; set; } + + /// MxAccess galaxy name (optional). + public string? GalaxyName { get; set; } + } +} +``` + +### 1.3 SubscriptionConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// Subscription channel settings. + public class SubscriptionConfiguration + { + /// Per-client subscription buffer size. Default: 1000. + public int ChannelCapacity { get; set; } = 1000; + + /// Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest. + public string ChannelFullMode { get; set; } = "DropOldest"; + } +} +``` + +### 1.4 TlsConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// TLS/SSL settings for the gRPC server. + public class TlsConfiguration + { + /// Enable TLS on the gRPC server. Default: false. + public bool Enabled { get; set; } = false; + + /// PEM server certificate path. Default: certs/server.crt. + public string ServerCertificatePath { get; set; } = "certs/server.crt"; + + /// PEM server private key path. Default: certs/server.key. + public string ServerKeyPath { get; set; } = "certs/server.key"; + + /// CA certificate for mutual TLS client validation. Default: certs/ca.crt. + public string ClientCaCertificatePath { get; set; } = "certs/ca.crt"; + + /// Require client certificates (mutual TLS). Default: false. + public bool RequireClientCertificate { get; set; } = false; + + /// Check certificate revocation lists. Default: false. + public bool CheckCertificateRevocation { get; set; } = false; + } +} +``` + +### 1.5 WebServerConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// HTTP status web server settings. + public class WebServerConfiguration + { + /// Enable the status web server. Default: true. + public bool Enabled { get; set; } = true; + + /// HTTP listen port. Default: 8080. + public int Port { get; set; } = 8080; + + /// Custom URL prefix (defaults to http://+:{Port}/ if null). + public string? Prefix { get; set; } + } +} +``` + +### 1.6 ServiceRecoveryConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// Windows SCM service recovery settings. + public class ServiceRecoveryConfiguration + { + /// Restart delay after first failure in minutes. Default: 1. + public int FirstFailureDelayMinutes { get; set; } = 1; + + /// Restart delay after second failure in minutes. Default: 5. + public int SecondFailureDelayMinutes { get; set; } = 5; + + /// Restart delay after subsequent failures in minutes. Default: 10. + public int SubsequentFailureDelayMinutes { get; set; } = 10; + + /// Days before failure count resets. Default: 1. + public int ResetPeriodDays { get; set; } = 1; + } +} +``` + +--- + +## Step 2: ConfigurationValidator + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Configuration +{ + /// + /// Validates the LmxProxy configuration at startup. + /// Throws InvalidOperationException on any validation error. + /// + public static class ConfigurationValidator + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); + + /// + /// Validates all configuration settings and logs the effective values. + /// Throws on first validation error. + /// + public static void ValidateAndLog(LmxProxyConfiguration config) + { + var errors = new List(); + + // GrpcPort + if (config.GrpcPort < 1 || config.GrpcPort > 65535) + errors.Add($"GrpcPort must be 1-65535, got {config.GrpcPort}"); + + // Connection + var conn = config.Connection; + if (conn.MonitorIntervalSeconds <= 0) + errors.Add($"Connection.MonitorIntervalSeconds must be > 0, got {conn.MonitorIntervalSeconds}"); + if (conn.ConnectionTimeoutSeconds <= 0) + errors.Add($"Connection.ConnectionTimeoutSeconds must be > 0, got {conn.ConnectionTimeoutSeconds}"); + if (conn.ReadTimeoutSeconds <= 0) + errors.Add($"Connection.ReadTimeoutSeconds must be > 0, got {conn.ReadTimeoutSeconds}"); + if (conn.WriteTimeoutSeconds <= 0) + errors.Add($"Connection.WriteTimeoutSeconds must be > 0, got {conn.WriteTimeoutSeconds}"); + if (conn.MaxConcurrentOperations <= 0) + errors.Add($"Connection.MaxConcurrentOperations must be > 0, got {conn.MaxConcurrentOperations}"); + if (conn.NodeName != null && conn.NodeName.Length > 255) + errors.Add("Connection.NodeName must be <= 255 characters"); + if (conn.GalaxyName != null && conn.GalaxyName.Length > 255) + errors.Add("Connection.GalaxyName must be <= 255 characters"); + + // Subscription + var sub = config.Subscription; + if (sub.ChannelCapacity < 0 || sub.ChannelCapacity > 100000) + errors.Add($"Subscription.ChannelCapacity must be 0-100000, got {sub.ChannelCapacity}"); + var validModes = new HashSet(StringComparer.OrdinalIgnoreCase) + { "DropOldest", "DropNewest", "Wait" }; + if (!validModes.Contains(sub.ChannelFullMode)) + errors.Add($"Subscription.ChannelFullMode must be DropOldest, DropNewest, or Wait, got '{sub.ChannelFullMode}'"); + + // ServiceRecovery + var sr = config.ServiceRecovery; + if (sr.FirstFailureDelayMinutes < 0) + errors.Add($"ServiceRecovery.FirstFailureDelayMinutes must be >= 0, got {sr.FirstFailureDelayMinutes}"); + if (sr.SecondFailureDelayMinutes < 0) + errors.Add($"ServiceRecovery.SecondFailureDelayMinutes must be >= 0, got {sr.SecondFailureDelayMinutes}"); + if (sr.SubsequentFailureDelayMinutes < 0) + errors.Add($"ServiceRecovery.SubsequentFailureDelayMinutes must be >= 0, got {sr.SubsequentFailureDelayMinutes}"); + if (sr.ResetPeriodDays <= 0) + errors.Add($"ServiceRecovery.ResetPeriodDays must be > 0, got {sr.ResetPeriodDays}"); + + // TLS + if (config.Tls.Enabled) + { + if (!File.Exists(config.Tls.ServerCertificatePath)) + Log.Warning("TLS enabled but server certificate not found at {Path} (will auto-generate)", + config.Tls.ServerCertificatePath); + if (!File.Exists(config.Tls.ServerKeyPath)) + Log.Warning("TLS enabled but server key not found at {Path} (will auto-generate)", + config.Tls.ServerKeyPath); + } + + // WebServer + if (config.WebServer.Enabled) + { + if (config.WebServer.Port < 1 || config.WebServer.Port > 65535) + errors.Add($"WebServer.Port must be 1-65535, got {config.WebServer.Port}"); + } + + if (errors.Count > 0) + { + foreach (var error in errors) + Log.Error("Configuration error: {Error}", error); + throw new InvalidOperationException( + $"Configuration validation failed with {errors.Count} error(s): {string.Join("; ", errors)}"); + } + + // Log effective configuration + Log.Information("Configuration validated successfully"); + Log.Information(" GrpcPort: {Port}", config.GrpcPort); + Log.Information(" ApiKeyConfigFile: {File}", config.ApiKeyConfigFile); + Log.Information(" Connection.AutoReconnect: {AutoReconnect}", conn.AutoReconnect); + Log.Information(" Connection.MonitorIntervalSeconds: {Interval}", conn.MonitorIntervalSeconds); + Log.Information(" Connection.MaxConcurrentOperations: {Max}", conn.MaxConcurrentOperations); + Log.Information(" Subscription.ChannelCapacity: {Capacity}", sub.ChannelCapacity); + Log.Information(" Subscription.ChannelFullMode: {Mode}", sub.ChannelFullMode); + Log.Information(" Tls.Enabled: {Enabled}", config.Tls.Enabled); + Log.Information(" WebServer.Enabled: {Enabled}, Port: {Port}", config.WebServer.Enabled, config.WebServer.Port); + } + } +} +``` + +--- + +## Step 3: ApiKey model and ApiKeyService + +### 3.1 ApiKey model + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// An API key with description, role, and enabled state. + public class ApiKey + { + public string Key { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly; + public bool Enabled { get; set; } = true; + } + + /// API key role for authorization. + public enum ApiKeyRole + { + /// Read and subscribe only. + ReadOnly, + /// Full access including writes. + ReadWrite + } +} +``` + +### 3.2 ApiKeyConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs` + +```csharp +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// JSON structure for the API key configuration file. + public class ApiKeyConfiguration + { + public List ApiKeys { get; set; } = new List(); + } +} +``` + +### 3.3 ApiKeyService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Newtonsoft.Json; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// + /// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher. + /// + public sealed class ApiKeyService : IDisposable + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly string _configFilePath; + private readonly FileSystemWatcher? _watcher; + private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1); + private volatile Dictionary _keys = new Dictionary(StringComparer.Ordinal); + private DateTime _lastReloadTime = DateTime.MinValue; + private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1); + + public ApiKeyService(string configFilePath) + { + _configFilePath = Path.GetFullPath(configFilePath); + + // Auto-generate default file if missing + if (!File.Exists(_configFilePath)) + { + GenerateDefaultKeyFile(); + } + + // Initial load + LoadKeys(); + + // Set up FileSystemWatcher for hot-reload + var directory = Path.GetDirectoryName(_configFilePath); + var fileName = Path.GetFileName(_configFilePath); + if (directory != null) + { + _watcher = new FileSystemWatcher(directory, fileName) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size, + EnableRaisingEvents = true + }; + _watcher.Changed += OnFileChanged; + } + } + + /// + /// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise. + /// + public ApiKey? ValidateApiKey(string apiKey) + { + if (string.IsNullOrEmpty(apiKey)) return null; + return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null; + } + + /// + /// Checks if a key has the required role. + /// ReadWrite implies ReadOnly. + /// + public bool HasRole(string apiKey, ApiKeyRole requiredRole) + { + var key = ValidateApiKey(apiKey); + if (key == null) return false; + + return requiredRole switch + { + ApiKeyRole.ReadOnly => true, // Both roles have ReadOnly + ApiKeyRole.ReadWrite => key.Role == ApiKeyRole.ReadWrite, + _ => false + }; + } + + /// Gets the count of loaded API keys. + public int KeyCount => _keys.Count; + + private void GenerateDefaultKeyFile() + { + Log.Information("API key file not found at {Path}, generating defaults", _configFilePath); + + var config = new ApiKeyConfiguration + { + ApiKeys = new List + { + new ApiKey + { + Key = GenerateRandomKey(), + Description = "Default ReadOnly key (auto-generated)", + Role = ApiKeyRole.ReadOnly, + Enabled = true + }, + new ApiKey + { + Key = GenerateRandomKey(), + Description = "Default ReadWrite key (auto-generated)", + Role = ApiKeyRole.ReadWrite, + Enabled = true + } + } + }; + + var directory = Path.GetDirectoryName(_configFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + var json = JsonConvert.SerializeObject(config, Formatting.Indented); + File.WriteAllText(_configFilePath, json); + Log.Information("Default API key file generated at {Path}", _configFilePath); + } + + private static string GenerateRandomKey() + { + // 32 random bytes → 64-char hex string + var bytes = new byte[32]; + using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + private void LoadKeys() + { + try + { + var json = File.ReadAllText(_configFilePath); + var config = JsonConvert.DeserializeObject(json); + if (config?.ApiKeys != null) + { + _keys = config.ApiKeys + .Where(k => !string.IsNullOrEmpty(k.Key)) + .ToDictionary(k => k.Key, k => k, StringComparer.Ordinal); + Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath); + } + else + { + Log.Warning("API key file at {Path} contained no keys", _configFilePath); + _keys = new Dictionary(StringComparer.Ordinal); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath); + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + // Debounce: ignore rapid changes within 1 second + if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return; + + if (_reloadLock.Wait(0)) + { + try + { + _lastReloadTime = DateTime.UtcNow; + Log.Information("API key file changed, reloading"); + + // Small delay to let the file system finish writing + Thread.Sleep(100); + LoadKeys(); + } + finally + { + _reloadLock.Release(); + } + } + } + + public void Dispose() + { + _watcher?.Dispose(); + _reloadLock.Dispose(); + } + } +} +``` + +--- + +## Step 4: ApiKeyInterceptor + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Serilog; + +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// + /// gRPC server interceptor that enforces API key authentication and role-based authorization. + /// Extracts x-api-key from metadata, validates via ApiKeyService, enforces ReadWrite for writes. + /// + public class ApiKeyInterceptor : Interceptor + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly ApiKeyService _apiKeyService; + + /// RPC method names that require the ReadWrite role. + private static readonly HashSet WriteProtectedMethods = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "/scada.ScadaService/Write", + "/scada.ScadaService/WriteBatch", + "/scada.ScadaService/WriteBatchAndWait" + }; + + public ApiKeyInterceptor(ApiKeyService apiKeyService) + { + _apiKeyService = apiKeyService; + } + + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + ValidateApiKey(context); + return await continuation(request, context); + } + + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation) + { + ValidateApiKey(context); + await continuation(request, responseStream, context); + } + + private void ValidateApiKey(ServerCallContext context) + { + // Extract x-api-key from metadata + var apiKeyEntry = context.RequestHeaders.Get("x-api-key"); + var apiKey = apiKeyEntry?.Value; + + if (string.IsNullOrEmpty(apiKey)) + { + Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method); + throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing x-api-key header")); + } + + var key = _apiKeyService.ValidateApiKey(apiKey); + if (key == null) + { + Log.Warning("Request rejected: invalid API key for {Method}", context.Method); + throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key")); + } + + // Check write authorization + if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite) + { + Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method); + throw new RpcException(new Status(StatusCode.PermissionDenied, + "Write operations require a ReadWrite API key")); + } + + // Store the validated key in UserState for downstream use + context.UserState["ApiKey"] = key; + } + } +} +``` + +--- + +## Step 5: TlsCertificateManager + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs` + +```csharp +using System.IO; +using Grpc.Core; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Configuration; + +namespace ZB.MOM.WW.LmxProxy.Host.Security +{ + /// + /// Manages TLS certificates for the gRPC server. + /// If TLS is enabled but certs are missing, logs a warning (self-signed generation + /// would be added as a future enhancement, or done manually). + /// + public static class TlsCertificateManager + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager)); + + /// + /// Creates gRPC server credentials based on TLS configuration. + /// Returns InsecureServerCredentials if TLS is disabled. + /// + public static ServerCredentials CreateServerCredentials(TlsConfiguration config) + { + if (!config.Enabled) + { + Log.Information("TLS disabled, using insecure server credentials"); + return ServerCredentials.Insecure; + } + + if (!File.Exists(config.ServerCertificatePath) || !File.Exists(config.ServerKeyPath)) + { + Log.Warning("TLS enabled but certificate files not found. Falling back to insecure credentials. " + + "Cert: {CertPath}, Key: {KeyPath}", + config.ServerCertificatePath, config.ServerKeyPath); + return ServerCredentials.Insecure; + } + + var certChain = File.ReadAllText(config.ServerCertificatePath); + var privateKey = File.ReadAllText(config.ServerKeyPath); + + var keyCertPair = new KeyCertificatePair(certChain, privateKey); + + if (config.RequireClientCertificate && File.Exists(config.ClientCaCertificatePath)) + { + var caCert = File.ReadAllText(config.ClientCaCertificatePath); + Log.Information("TLS enabled with mutual TLS (client certificate required)"); + return new SslServerCredentials( + new[] { keyCertPair }, + caCert, + SslClientCertificateRequestType.RequestAndRequireAndVerify); + } + + Log.Information("TLS enabled (server-only)"); + return new SslServerCredentials(new[] { keyCertPair }); + } + } +} +``` + +--- + +## Step 6: ScadaGrpcService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs` + +This file implements all 10 RPCs. It inherits from the proto-generated `Scada.ScadaService.ScadaServiceBase` base class. The proto codegen produces this base class from the `service ScadaService { ... }` in `scada.proto`. + +```csharp +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Grpc.Core; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Domain; +using ZB.MOM.WW.LmxProxy.Host.Sessions; +using ZB.MOM.WW.LmxProxy.Host.Subscriptions; + +namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services +{ + /// + /// gRPC service implementation for all 10 SCADA RPCs. + /// Inherits from proto-generated ScadaService.ScadaServiceBase. + /// + public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly IScadaClient _scadaClient; + private readonly SessionManager _sessionManager; + private readonly SubscriptionManager _subscriptionManager; + + public ScadaGrpcService( + IScadaClient scadaClient, + SessionManager sessionManager, + SubscriptionManager subscriptionManager) + { + _scadaClient = scadaClient; + _sessionManager = sessionManager; + _subscriptionManager = subscriptionManager; + } + + // ── Connection Management ───────────────────────────────── + + public override Task Connect( + Scada.ConnectRequest request, ServerCallContext context) + { + try + { + if (!_scadaClient.IsConnected) + { + return Task.FromResult(new Scada.ConnectResponse + { + Success = false, + Message = "MxAccess is not connected" + }); + } + + var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey); + + return Task.FromResult(new Scada.ConnectResponse + { + Success = true, + Message = "Connected", + SessionId = sessionId + }); + } + catch (Exception ex) + { + Log.Error(ex, "Connect failed for client {ClientId}", request.ClientId); + return Task.FromResult(new Scada.ConnectResponse + { + Success = false, + Message = ex.Message + }); + } + } + + public override Task Disconnect( + Scada.DisconnectRequest request, ServerCallContext context) + { + try + { + // Clean up subscriptions for this session + _subscriptionManager.UnsubscribeClient(request.SessionId); + + var terminated = _sessionManager.TerminateSession(request.SessionId); + return Task.FromResult(new Scada.DisconnectResponse + { + Success = terminated, + Message = terminated ? "Disconnected" : "Session not found" + }); + } + catch (Exception ex) + { + Log.Error(ex, "Disconnect failed for session {SessionId}", request.SessionId); + return Task.FromResult(new Scada.DisconnectResponse + { + Success = false, + Message = ex.Message + }); + } + } + + public override Task GetConnectionState( + Scada.GetConnectionStateRequest request, ServerCallContext context) + { + var session = _sessionManager.GetSession(request.SessionId); + return Task.FromResult(new Scada.GetConnectionStateResponse + { + IsConnected = _scadaClient.IsConnected, + ClientId = session?.ClientId ?? "", + ConnectedSinceUtcTicks = session?.ConnectedSinceUtcTicks ?? 0 + }); + } + + // ── Read Operations ──────────────────────────────────────── + + public override async Task Read( + Scada.ReadRequest request, ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + return new Scada.ReadResponse + { + Success = false, + Message = "Invalid session", + Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.Bad()) + }; + } + + try + { + var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken); + return new Scada.ReadResponse + { + Success = true, + Message = "", + Vtq = ConvertToProtoVtq(request.Tag, vtq) + }; + } + catch (Exception ex) + { + Log.Error(ex, "Read failed for tag {Tag}", request.Tag); + return new Scada.ReadResponse + { + Success = false, + Message = ex.Message, + Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.BadCommunicationFailure()) + }; + } + } + + public override async Task ReadBatch( + Scada.ReadBatchRequest request, ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + return new Scada.ReadBatchResponse + { + Success = false, + Message = "Invalid session" + }; + } + + try + { + var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken); + + var response = new Scada.ReadBatchResponse + { + Success = true, + Message = "" + }; + + // Return results in request order + foreach (var tag in request.Tags) + { + if (results.TryGetValue(tag, out var vtq)) + { + response.Vtqs.Add(ConvertToProtoVtq(tag, vtq)); + } + else + { + response.Vtqs.Add(CreateBadVtq(tag, QualityCodeMapper.BadConfigurationError())); + } + } + + return response; + } + catch (Exception ex) + { + Log.Error(ex, "ReadBatch failed"); + return new Scada.ReadBatchResponse + { + Success = false, + Message = ex.Message + }; + } + } + + // ── Write Operations ─────────────────────────────────────── + + public override async Task Write( + Scada.WriteRequest request, ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + return new Scada.WriteResponse { Success = false, Message = "Invalid session" }; + } + + try + { + var value = TypedValueConverter.FromTypedValue(request.Value); + await _scadaClient.WriteAsync(request.Tag, value!, context.CancellationToken); + return new Scada.WriteResponse { Success = true, Message = "" }; + } + catch (Exception ex) + { + Log.Error(ex, "Write failed for tag {Tag}", request.Tag); + return new Scada.WriteResponse { Success = false, Message = ex.Message }; + } + } + + public override async Task WriteBatch( + Scada.WriteBatchRequest request, ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" }; + } + + var response = new Scada.WriteBatchResponse { Success = true, Message = "" }; + + foreach (var item in request.Items) + { + try + { + var value = TypedValueConverter.FromTypedValue(item.Value); + await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); + response.Results.Add(new Scada.WriteResult + { + Tag = item.Tag, Success = true, Message = "" + }); + } + catch (Exception ex) + { + response.Success = false; + response.Results.Add(new Scada.WriteResult + { + Tag = item.Tag, Success = false, Message = ex.Message + }); + } + } + + return response; + } + + public override async Task WriteBatchAndWait( + Scada.WriteBatchAndWaitRequest request, ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + return new Scada.WriteBatchAndWaitResponse { Success = false, Message = "Invalid session" }; + } + + var response = new Scada.WriteBatchAndWaitResponse { Success = true }; + + // Write all items first + var values = request.Items.ToDictionary( + i => i.Tag, + i => TypedValueConverter.FromTypedValue(i.Value)!); + + try + { + // Execute writes and collect results + foreach (var item in request.Items) + { + try + { + var value = TypedValueConverter.FromTypedValue(item.Value); + await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); + response.WriteResults.Add(new Scada.WriteResult + { + Tag = item.Tag, Success = true, Message = "" + }); + } + catch (Exception ex) + { + response.Success = false; + response.Message = "One or more writes failed"; + response.WriteResults.Add(new Scada.WriteResult + { + Tag = item.Tag, Success = false, Message = ex.Message + }); + } + } + + // If any write failed, return immediately + if (!response.Success) + return response; + + // Poll flag tag + var flagValue = TypedValueConverter.FromTypedValue(request.FlagValue); + var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000; + var pollIntervalMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; + + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + var vtq = await _scadaClient.ReadAsync(request.FlagTag, context.CancellationToken); + if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue)) + { + response.FlagReached = true; + response.ElapsedMs = (int)sw.ElapsedMilliseconds; + return response; + } + + await Task.Delay(pollIntervalMs, context.CancellationToken); + } + + // Timeout — not an error + response.FlagReached = false; + response.ElapsedMs = (int)sw.ElapsedMilliseconds; + return response; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Log.Error(ex, "WriteBatchAndWait failed"); + return new Scada.WriteBatchAndWaitResponse + { + Success = false, Message = ex.Message + }; + } + } + + // ── Subscription ─────────────────────────────────────────── + + public override async Task Subscribe( + Scada.SubscribeRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + if (!_sessionManager.ValidateSession(request.SessionId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session")); + } + + var reader = _subscriptionManager.Subscribe( + request.SessionId, request.Tags, context.CancellationToken); + + try + { + while (await reader.WaitToReadAsync(context.CancellationToken)) + { + while (reader.TryRead(out var item)) + { + var protoVtq = ConvertToProtoVtq(item.address, item.vtq); + await responseStream.WriteAsync(protoVtq); + } + } + } + catch (OperationCanceledException) + { + // Client disconnected — normal + } + catch (Exception ex) + { + Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId); + throw new RpcException(new Status(StatusCode.Internal, ex.Message)); + } + finally + { + _subscriptionManager.UnsubscribeClient(request.SessionId); + } + } + + // ── API Key Check ────────────────────────────────────────── + + public override Task CheckApiKey( + Scada.CheckApiKeyRequest request, ServerCallContext context) + { + // The interceptor already validated the x-api-key header. + // This RPC lets clients explicitly check a specific key. + // The validated key from the interceptor is in context.UserState. + var isValid = context.UserState.ContainsKey("ApiKey"); + return Task.FromResult(new Scada.CheckApiKeyResponse + { + IsValid = isValid, + Message = isValid ? "Valid" : "Invalid" + }); + } + + // ── Helpers ──────────────────────────────────────────────── + + /// Converts a domain Vtq to a proto VtqMessage. + private static Scada.VtqMessage ConvertToProtoVtq(string tag, Vtq vtq) + { + return new Scada.VtqMessage + { + Tag = tag, + Value = TypedValueConverter.ToTypedValue(vtq.Value), + TimestampUtcTicks = vtq.Timestamp.Ticks, + Quality = QualityCodeMapper.ToQualityCode(vtq.Quality) + }; + } + + /// Creates a VtqMessage with bad quality for error responses. + private static Scada.VtqMessage CreateBadVtq(string tag, Scada.QualityCode quality) + { + return new Scada.VtqMessage + { + Tag = tag, + TimestampUtcTicks = DateTime.UtcNow.Ticks, + Quality = quality + }; + } + } +} +``` + +--- + +## Step 7: LmxProxyService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` + +```csharp +using System; +using System.Threading; +using Grpc.Core; +using Serilog; +using ZB.MOM.WW.LmxProxy.Host.Configuration; +using ZB.MOM.WW.LmxProxy.Host.Grpc.Services; +using ZB.MOM.WW.LmxProxy.Host.MxAccess; +using ZB.MOM.WW.LmxProxy.Host.Security; +using ZB.MOM.WW.LmxProxy.Host.Sessions; +using ZB.MOM.WW.LmxProxy.Host.Subscriptions; + +namespace ZB.MOM.WW.LmxProxy.Host +{ + /// + /// Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue. + /// + public class LmxProxyService + { + private static readonly ILogger Log = Serilog.Log.ForContext(); + + private readonly LmxProxyConfiguration _config; + + private MxAccessClient? _mxAccessClient; + private SessionManager? _sessionManager; + private SubscriptionManager? _subscriptionManager; + private ApiKeyService? _apiKeyService; + private Server? _grpcServer; + + public LmxProxyService(LmxProxyConfiguration config) + { + _config = config; + } + + /// + /// Topshelf Start callback. Creates and starts all components. + /// + public bool Start() + { + try + { + Log.Information("LmxProxy service starting..."); + + // 1. Validate configuration + ConfigurationValidator.ValidateAndLog(_config); + + // 2. Check/generate TLS certificates + var credentials = TlsCertificateManager.CreateServerCredentials(_config.Tls); + + // 3. Create ApiKeyService + _apiKeyService = new ApiKeyService(_config.ApiKeyConfigFile); + + // 4. Create MxAccessClient + _mxAccessClient = new MxAccessClient( + maxConcurrentOperations: _config.Connection.MaxConcurrentOperations, + readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds, + writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds, + monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds, + autoReconnect: _config.Connection.AutoReconnect, + nodeName: _config.Connection.NodeName, + galaxyName: _config.Connection.GalaxyName); + + // 5. Connect to MxAccess synchronously (with timeout) + Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...", + _config.Connection.ConnectionTimeoutSeconds); + using (var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(_config.Connection.ConnectionTimeoutSeconds))) + { + _mxAccessClient.ConnectAsync(cts.Token).GetAwaiter().GetResult(); + } + + // 6. Start auto-reconnect monitor + _mxAccessClient.StartMonitorLoop(); + + // 7. Create SubscriptionManager + var channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest; + if (_config.Subscription.ChannelFullMode.Equals("DropNewest", StringComparison.OrdinalIgnoreCase)) + channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropNewest; + else if (_config.Subscription.ChannelFullMode.Equals("Wait", StringComparison.OrdinalIgnoreCase)) + channelFullMode = System.Threading.Channels.BoundedChannelFullMode.Wait; + + _subscriptionManager = new SubscriptionManager( + _mxAccessClient, _config.Subscription.ChannelCapacity, channelFullMode); + + // Wire MxAccessClient data change events to SubscriptionManager + _mxAccessClient.OnTagValueChanged = _subscriptionManager.OnTagValueChanged; + + // Wire MxAccessClient disconnect to SubscriptionManager + _mxAccessClient.ConnectionStateChanged += (sender, e) => + { + if (e.CurrentState == Domain.ConnectionState.Disconnected || + e.CurrentState == Domain.ConnectionState.Error) + { + _subscriptionManager.NotifyDisconnection(); + } + }; + + // 8. Create SessionManager + _sessionManager = new SessionManager(inactivityTimeoutMinutes: 5); + + // 9. Create gRPC service + var grpcService = new ScadaGrpcService( + _mxAccessClient, _sessionManager, _subscriptionManager); + + // 10. Create and configure interceptor + var interceptor = new ApiKeyInterceptor(_apiKeyService); + + // 11. Build and start gRPC server + _grpcServer = new Server + { + Services = + { + Scada.ScadaService.BindService(grpcService) + .Intercept(interceptor) + }, + Ports = + { + new ServerPort("0.0.0.0", _config.GrpcPort, credentials) + } + }; + + _grpcServer.Start(); + Log.Information("gRPC server started on port {Port}", _config.GrpcPort); + + Log.Information("LmxProxy service started successfully"); + return true; + } + catch (Exception ex) + { + Log.Fatal(ex, "LmxProxy service failed to start"); + return false; + } + } + + /// + /// Topshelf Stop callback. Stops and disposes all components in reverse order. + /// + public bool Stop() + { + Log.Information("LmxProxy service stopping..."); + + try + { + // 1. Stop reconnect monitor (5s wait) + _mxAccessClient?.StopMonitorLoop(); + + // 2. Graceful gRPC shutdown (10s timeout, then kill) + if (_grpcServer != null) + { + Log.Information("Shutting down gRPC server..."); + _grpcServer.ShutdownAsync().Wait(TimeSpan.FromSeconds(10)); + Log.Information("gRPC server stopped"); + } + + // 3. Dispose components in reverse order + _subscriptionManager?.Dispose(); + _sessionManager?.Dispose(); + _apiKeyService?.Dispose(); + + // 4. Disconnect MxAccess (10s timeout) + if (_mxAccessClient != null) + { + Log.Information("Disconnecting from MxAccess..."); + _mxAccessClient.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10)); + Log.Information("MxAccess disconnected"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error during shutdown"); + } + + Log.Information("LmxProxy service stopped"); + return true; + } + + /// Topshelf Pause callback — no-op. + public bool Pause() + { + Log.Information("LmxProxy service paused (no-op)"); + return true; + } + + /// Topshelf Continue callback — no-op. + public bool Continue() + { + Log.Information("LmxProxy service continued (no-op)"); + return true; + } + } +} +``` + +--- + +## Step 8: Program.cs + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` + +Replace the Phase 1 placeholder with the full Topshelf entry point: + +```csharp +using System; +using Microsoft.Extensions.Configuration; +using Serilog; +using Topshelf; +using ZB.MOM.WW.LmxProxy.Host.Configuration; + +namespace ZB.MOM.WW.LmxProxy.Host +{ + internal static class Program + { + static int Main(string[] args) + { + // 1. Build configuration + var configuration = new ConfigurationBuilder() + .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + // 2. Configure Serilog + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .CreateLogger(); + + try + { + // 3. Bind configuration + var config = new LmxProxyConfiguration(); + configuration.Bind(config); + + // 4. Configure Topshelf + var exitCode = HostFactory.Run(host => + { + host.UseSerilog(); + + host.Service(service => + { + service.ConstructUsing(() => new LmxProxyService(config)); + service.WhenStarted(s => s.Start()); + service.WhenStopped(s => s.Stop()); + service.WhenPaused(s => s.Pause()); + service.WhenContinued(s => s.Continue()); + service.WhenShutdown(s => s.Stop()); + }); + + host.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); + host.SetDisplayName("SCADA Bridge LMX Proxy"); + host.SetDescription("gRPC proxy for AVEVA System Platform via MXAccess COM API"); + + host.StartAutomatically(); + host.EnablePauseAndContinue(); + + host.EnableServiceRecovery(recovery => + { + recovery.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); + recovery.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); + recovery.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); + recovery.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); + }); + }); + + return (int)exitCode; + } + catch (Exception ex) + { + Log.Fatal(ex, "LmxProxy service terminated unexpectedly"); + return 1; + } + finally + { + Log.CloseAndFlush(); + } + } + } +} +``` + +--- + +## Step 9: appsettings.json + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` + +Replace the Phase 1 placeholder with the complete default configuration: + +```json +{ + "GrpcPort": 50051, + "ApiKeyConfigFile": "apikeys.json", + + "Connection": { + "MonitorIntervalSeconds": 5, + "ConnectionTimeoutSeconds": 30, + "ReadTimeoutSeconds": 5, + "WriteTimeoutSeconds": 5, + "MaxConcurrentOperations": 10, + "AutoReconnect": true, + "NodeName": null, + "GalaxyName": null + }, + + "Subscription": { + "ChannelCapacity": 1000, + "ChannelFullMode": "DropOldest" + }, + + "Tls": { + "Enabled": false, + "ServerCertificatePath": "certs/server.crt", + "ServerKeyPath": "certs/server.key", + "ClientCaCertificatePath": "certs/ca.crt", + "RequireClientCertificate": false, + "CheckCertificateRevocation": false + }, + + "WebServer": { + "Enabled": true, + "Port": 8080 + }, + + "ServiceRecovery": { + "FirstFailureDelayMinutes": 1, + "SecondFailureDelayMinutes": 5, + "SubsequentFailureDelayMinutes": 10, + "ResetPeriodDays": 1 + }, + + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File", + "Serilog.Enrichers.Environment", + "Serilog.Enrichers.Thread" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning", + "Grpc": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "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}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} +``` + +--- + +## Step 10: Unit tests + +### 10.1 ConfigurationValidator tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs` + +```csharp +using System; +using FluentAssertions; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Configuration; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration +{ + public class ConfigurationValidatorTests + { + private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration(); + + [Fact] + public void ValidConfig_PassesValidation() + { + var config = ValidConfig(); + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().NotThrow(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(70000)] + public void InvalidGrpcPort_Throws(int port) + { + var config = ValidConfig(); + config.GrpcPort = port; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("GrpcPort")); + } + + [Fact] + public void InvalidMonitorInterval_Throws() + { + var config = ValidConfig(); + config.Connection.MonitorIntervalSeconds = 0; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("MonitorIntervalSeconds")); + } + + [Fact] + public void InvalidChannelCapacity_Throws() + { + var config = ValidConfig(); + config.Subscription.ChannelCapacity = -1; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("ChannelCapacity")); + } + + [Fact] + public void InvalidChannelFullMode_Throws() + { + var config = ValidConfig(); + config.Subscription.ChannelFullMode = "InvalidMode"; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("ChannelFullMode")); + } + + [Fact] + public void InvalidResetPeriodDays_Throws() + { + var config = ValidConfig(); + config.ServiceRecovery.ResetPeriodDays = 0; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("ResetPeriodDays")); + } + + [Fact] + public void NegativeFailureDelay_Throws() + { + var config = ValidConfig(); + config.ServiceRecovery.FirstFailureDelayMinutes = -1; + var act = () => ConfigurationValidator.ValidateAndLog(config); + act.Should().Throw().Where(e => e.Message.Contains("FirstFailureDelayMinutes")); + } + } +} +``` + +### 10.2 ApiKeyService tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs` + +```csharp +using System; +using System.IO; +using FluentAssertions; +using Newtonsoft.Json; +using Xunit; +using ZB.MOM.WW.LmxProxy.Host.Security; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security +{ + public class ApiKeyServiceTests : IDisposable + { + private readonly string _tempDir; + + public ApiKeyServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private string CreateKeyFile(params ApiKey[] keys) + { + var path = Path.Combine(_tempDir, "apikeys.json"); + var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List(keys) }; + File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented)); + return path; + } + + [Fact] + public void AutoGeneratesDefaultFile_WhenMissing() + { + var path = Path.Combine(_tempDir, "missing.json"); + using var svc = new ApiKeyService(path); + File.Exists(path).Should().BeTrue(); + svc.KeyCount.Should().Be(2); + } + + [Fact] + public void ValidateApiKey_ReturnsKey_WhenValid() + { + var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); + using var svc = new ApiKeyService(path); + var key = svc.ValidateApiKey("test-key"); + key.Should().NotBeNull(); + key!.Role.Should().Be(ApiKeyRole.ReadWrite); + } + + [Fact] + public void ValidateApiKey_ReturnsNull_WhenInvalid() + { + var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); + using var svc = new ApiKeyService(path); + svc.ValidateApiKey("wrong-key").Should().BeNull(); + } + + [Fact] + public void ValidateApiKey_ReturnsNull_WhenDisabled() + { + var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false }); + using var svc = new ApiKeyService(path); + svc.ValidateApiKey("test-key").Should().BeNull(); + } + + [Fact] + public void HasRole_ReadWrite_CanRead() + { + var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); + using var svc = new ApiKeyService(path); + svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue(); + } + + [Fact] + public void HasRole_ReadOnly_CannotWrite() + { + var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true }); + using var svc = new ApiKeyService(path); + svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse(); + } + + [Fact] + public void HasRole_ReadWrite_CanWrite() + { + var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); + using var svc = new ApiKeyService(path); + svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue(); + } + + [Fact] + public void ValidateApiKey_EmptyString_ReturnsNull() + { + var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true }); + using var svc = new ApiKeyService(path); + svc.ValidateApiKey("").Should().BeNull(); + svc.ValidateApiKey(null!).Should().BeNull(); + } + } +} +``` + +### 10.3 ApiKeyInterceptor tests + +Testing the interceptor in isolation requires mocking `ServerCallContext`, which is complex with Grpc.Core. Instead, verify the behavior through integration-style tests or verify the write-protected method set: + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs` + +```csharp +using FluentAssertions; +using Xunit; + +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security +{ + public class ApiKeyInterceptorTests + { + [Theory] + [InlineData("/scada.ScadaService/Write")] + [InlineData("/scada.ScadaService/WriteBatch")] + [InlineData("/scada.ScadaService/WriteBatchAndWait")] + public void WriteProtectedMethods_AreCorrectlyDefined(string method) + { + // This test verifies the set of write-protected methods is correct. + // The actual interceptor logic is tested via integration tests. + var writeProtected = new System.Collections.Generic.HashSet( + System.StringComparer.OrdinalIgnoreCase) + { + "/scada.ScadaService/Write", + "/scada.ScadaService/WriteBatch", + "/scada.ScadaService/WriteBatchAndWait" + }; + writeProtected.Should().Contain(method); + } + + [Theory] + [InlineData("/scada.ScadaService/Connect")] + [InlineData("/scada.ScadaService/Disconnect")] + [InlineData("/scada.ScadaService/GetConnectionState")] + [InlineData("/scada.ScadaService/Read")] + [InlineData("/scada.ScadaService/ReadBatch")] + [InlineData("/scada.ScadaService/Subscribe")] + [InlineData("/scada.ScadaService/CheckApiKey")] + public void ReadMethods_AreNotWriteProtected(string method) + { + var writeProtected = new System.Collections.Generic.HashSet( + System.StringComparer.OrdinalIgnoreCase) + { + "/scada.ScadaService/Write", + "/scada.ScadaService/WriteBatch", + "/scada.ScadaService/WriteBatchAndWait" + }; + writeProtected.Should().NotContain(method); + } + } +} +``` + +--- + +## Step 11: Build verification + +```bash +cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy + +# Build Client (works on macOS) +dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj + +# Run Client tests +dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj + +# Host builds on Windows only (net48/x86): +# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj +``` + +--- + +## Completion Criteria + +- [ ] All 6 configuration classes compile with correct defaults +- [ ] `ConfigurationValidator.ValidateAndLog()` catches all invalid values and tests pass +- [ ] `ApiKey` model and `ApiKeyConfiguration` compile +- [ ] `ApiKeyService` compiles: load, validate, hot-reload, auto-generate, and all tests pass +- [ ] `ApiKeyInterceptor` compiles: x-api-key extraction, validation, write protection +- [ ] `TlsCertificateManager` compiles: insecure fallback, server TLS, mutual TLS +- [ ] `ScadaGrpcService` compiles with all 10 RPCs implemented: + - Connect, Disconnect, GetConnectionState + - Read, ReadBatch (TypedValue + QualityCode responses) + - Write, WriteBatch, WriteBatchAndWait (TypedValue input, TypedValueEquals for flag comparison) + - Subscribe (server streaming from SubscriptionManager channel) + - CheckApiKey +- [ ] `LmxProxyService` compiles with full Start/Stop/Pause/Continue lifecycle +- [ ] `Program.cs` compiles with Topshelf configuration, Serilog setup, service recovery +- [ ] `appsettings.json` contains all default configuration values +- [ ] All unit tests pass (ConfigurationValidator, ApiKeyService, ApiKeyInterceptor) +- [ ] No v1 string serialization code: no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons +- [ ] All quality codes use `QualityCodeMapper` factory methods +- [ ] All value conversions use `TypedValueConverter` diff --git a/lmxproxy/docs/plans/phase-4-host-health-metrics.md b/lmxproxy/docs/plans/phase-4-host-health-metrics.md new file mode 100644 index 0000000..9095de2 --- /dev/null +++ b/lmxproxy/docs/plans/phase-4-host-health-metrics.md @@ -0,0 +1,666 @@ +# Phase 4: Host Health, Metrics & Status Server — Implementation Plan + +**Date**: 2026-03-21 +**Prerequisites**: Phase 3 complete and passing (gRPC server, Security, Configuration, Service Hosting all functional) +**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` + +## Guardrails + +1. **This is a v2 rebuild** — do not copy code from the v1 reference in `src-reference/`. Write fresh implementations guided by the design docs and the reference code's structure. +2. **Host targets .NET Framework 4.8, x86** — all code must use C# 9.0 language features maximum (`LangVersion` is `9.0` in the csproj). No file-scoped namespaces, no `required` keyword, no collection expressions in Host code. +3. **No new NuGet packages** — all required packages are already in the Host `.csproj` (`Microsoft.Extensions.Diagnostics.HealthChecks`, `Serilog`, `System.Threading.Channels`, `System.Text.Json` via framework). +4. **Namespace**: `ZB.MOM.WW.LmxProxy.Host` with sub-namespaces matching folder structure (e.g., `ZB.MOM.WW.LmxProxy.Host.Health`, `ZB.MOM.WW.LmxProxy.Host.Metrics`, `ZB.MOM.WW.LmxProxy.Host.Status`). +5. **All COM operations are on the STA thread** — health checks that read test tags must go through `MxAccessClient.ReadAsync()`, never directly touching COM objects. +6. **Build must pass after each step**: `dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86` +7. **Tests run on windev**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86` + +## Step 1: Create PerformanceMetrics + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs` + +Create the `PerformanceMetrics` class in namespace `ZB.MOM.WW.LmxProxy.Host.Metrics`. + +### 1.1 OperationMetrics (nested or separate class in same file) + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Metrics +{ + public class OperationMetrics + { + private readonly List _durations = new List(); + private readonly object _lock = new object(); + private long _totalCount; + private long _successCount; + private double _totalMilliseconds; + private double _minMilliseconds = double.MaxValue; + private double _maxMilliseconds; + + public void Record(TimeSpan duration, bool success) { ... } + public MetricsStatistics GetStatistics() { ... } + } +} +``` + +Implementation details: +- `Record(TimeSpan duration, bool success)`: Inside `lock (_lock)`, increment `_totalCount`, conditionally increment `_successCount`, add `duration.TotalMilliseconds` to `_durations` list, update `_totalMilliseconds`, `_minMilliseconds`, `_maxMilliseconds`. If `_durations.Count > 1000`, call `_durations.RemoveAt(0)` to maintain rolling buffer. +- `GetStatistics()`: Inside `lock (_lock)`, return early with empty `MetricsStatistics` if `_totalCount == 0`. Otherwise sort `_durations`, compute p95 index as `(int)Math.Ceiling(sortedDurations.Count * 0.95) - 1`, clamp to `Math.Max(0, p95Index)`. + +### 1.2 MetricsStatistics + +```csharp +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; } +} +``` + +### 1.3 ITimingScope interface and TimingScope implementation + +```csharp +public interface ITimingScope : IDisposable +{ + void SetSuccess(bool success); +} +``` + +`TimingScope` is a private nested class inside `PerformanceMetrics`: +- Constructor takes `PerformanceMetrics metrics, string operationName`, starts a `Stopwatch`. +- `SetSuccess(bool success)` stores the flag (default `true`). +- `Dispose()`: stops stopwatch, calls `_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success)`. Guard against double-dispose with `_disposed` flag. + +### 1.4 PerformanceMetrics class + +```csharp +public class PerformanceMetrics : IDisposable +{ + private static readonly ILogger Logger = Log.ForContext(); + private readonly ConcurrentDictionary _metrics = new ConcurrentDictionary(); + private readonly Timer _reportingTimer; + private bool _disposed; + + public PerformanceMetrics() + { + _reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + } + + public void RecordOperation(string operationName, TimeSpan duration, bool success = true) { ... } + public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName); + public OperationMetrics? GetMetrics(string operationName) { ... } + public IReadOnlyDictionary GetAllMetrics() { ... } + public Dictionary GetStatistics() { ... } + + private void ReportMetrics(object? state) { ... } // Log each operation's stats at Information level + public void Dispose() { ... } // Dispose timer, call ReportMetrics one final time +} +``` + +`ReportMetrics` iterates `_metrics`, calls `GetStatistics()` on each, logs via Serilog structured logging with properties: `Operation`, `Count`, `SuccessRate`, `AverageMs`, `MinMs`, `MaxMs`, `P95Ms`. + +### 1.5 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" +``` + +## Step 2: Create HealthCheckService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs` + +Namespace: `ZB.MOM.WW.LmxProxy.Host.Health` + +### 2.1 Basic HealthCheckService + +```csharp +public class HealthCheckService : IHealthCheck +{ + private static readonly ILogger Logger = Log.ForContext(); + private readonly IScadaClient _scadaClient; + private readonly SubscriptionManager _subscriptionManager; + private readonly PerformanceMetrics _performanceMetrics; + + public HealthCheckService( + IScadaClient scadaClient, + SubscriptionManager subscriptionManager, + PerformanceMetrics performanceMetrics) { ... } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) { ... } +} +``` + +Dependencies imported: +- `ZB.MOM.WW.LmxProxy.Host.Domain` for `IScadaClient`, `ConnectionState` +- `ZB.MOM.WW.LmxProxy.Host.Services` for `SubscriptionManager` (if still in that namespace after Phase 2/3; adjust import to match actual location) +- `ZB.MOM.WW.LmxProxy.Host.Metrics` for `PerformanceMetrics` +- `Microsoft.Extensions.Diagnostics.HealthChecks` for `IHealthCheck`, `HealthCheckResult`, `HealthCheckContext` + +`CheckHealthAsync` logic: +1. Create `Dictionary data`. +2. Read `_scadaClient.IsConnected` and `_scadaClient.ConnectionState` into `data["scada_connected"]` and `data["scada_connection_state"]`. +3. Get subscription stats via `_subscriptionManager.GetSubscriptionStats()` — store `TotalClients`, `TotalTags` in data. +4. Iterate `_performanceMetrics.GetAllMetrics()` to compute `totalOperations` and `averageSuccessRate`. +5. Store `total_operations` and `average_success_rate` in data. +6. Decision tree: + - If `!isConnected` → `HealthCheckResult.Unhealthy("SCADA client is not connected", data: data)` + - If `averageSuccessRate < 0.5 && totalOperations > 100` → `HealthCheckResult.Degraded(...)` + - If `subscriptionStats.TotalClients > 100` → `HealthCheckResult.Degraded(...)` + - Otherwise → `HealthCheckResult.Healthy("LmxProxy is healthy", data)` +7. Wrap everything in try/catch — on exception return `Unhealthy` with exception details. + +### 2.2 DetailedHealthCheckService + +In the same file or a separate file `src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs`: + +```csharp +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") { ... } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) { ... } +} +``` + +`CheckHealthAsync` logic: +1. If `!_scadaClient.IsConnected` → return `Unhealthy`. +2. Try `Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken)`. +3. If `vtq.Quality != Quality.Good` → return `Degraded` with quality info. +4. If `DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)` → return `Degraded` (stale data). +5. Otherwise → `Healthy`. +6. Catch read exceptions → return `Degraded("Could not read test tag")`. +7. Catch all exceptions → return `Unhealthy`. + +### 2.3 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" +``` + +## Step 3: Create StatusReportService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs` + +Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` + +### 3.1 Data model classes + +Define in the same file (or a separate `StatusModels.cs` in the same folder): + +```csharp +public class StatusData +{ + public DateTime Timestamp { get; set; } + public string ServiceName { get; set; } = ""; + public string Version { get; set; } = ""; + public ConnectionStatus Connection { get; set; } = new ConnectionStatus(); + public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus(); + public PerformanceStatus Performance { get; set; } = new PerformanceStatus(); + public HealthInfo Health { get; set; } = new HealthInfo(); + public HealthInfo? DetailedHealth { get; set; } +} + +public class ConnectionStatus +{ + public bool IsConnected { get; set; } + public string State { get; set; } = ""; + public string NodeName { get; set; } = ""; + public string GalaxyName { get; set; } = ""; +} + +public class SubscriptionStatus +{ + public int TotalClients { get; set; } + public int TotalTags { get; set; } + public int ActiveSubscriptions { get; set; } +} + +public class PerformanceStatus +{ + public long TotalOperations { get; set; } + public double AverageSuccessRate { get; set; } + public Dictionary Operations { get; set; } = new Dictionary(); +} + +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; } + public double Percentile95Milliseconds { get; set; } +} + +public class HealthInfo +{ + public string Status { get; set; } = ""; + public string Description { get; set; } = ""; + public Dictionary Data { get; set; } = new Dictionary(); +} +``` + +### 3.2 StatusReportService + +```csharp +public class StatusReportService +{ + private static readonly ILogger Logger = Log.ForContext(); + private readonly IScadaClient _scadaClient; + private readonly SubscriptionManager _subscriptionManager; + private readonly PerformanceMetrics _performanceMetrics; + private readonly HealthCheckService _healthCheckService; + private readonly DetailedHealthCheckService? _detailedHealthCheckService; + + public StatusReportService( + IScadaClient scadaClient, + SubscriptionManager subscriptionManager, + PerformanceMetrics performanceMetrics, + HealthCheckService healthCheckService, + DetailedHealthCheckService? detailedHealthCheckService = null) { ... } + + public async Task GenerateHtmlReportAsync() { ... } + public async Task GenerateJsonReportAsync() { ... } + public async Task IsHealthyAsync() { ... } + private async Task CollectStatusDataAsync() { ... } + private static string GenerateHtmlFromStatusData(StatusData statusData) { ... } + private static string GenerateErrorHtml(Exception ex) { ... } +} +``` + +`CollectStatusDataAsync`: +- Populate `StatusData.Timestamp = DateTime.UtcNow`, `ServiceName = "ZB.MOM.WW.LmxProxy.Host"`, `Version` from `Assembly.GetExecutingAssembly().GetName().Version`. +- Connection info from `_scadaClient.IsConnected`, `_scadaClient.ConnectionState`. +- Subscription stats from `_subscriptionManager.GetSubscriptionStats()`. +- Performance stats from `_performanceMetrics.GetStatistics()` — include P95 in the `OperationStatus`. +- Health from `_healthCheckService.CheckHealthAsync(new HealthCheckContext())`. +- Detailed health from `_detailedHealthCheckService?.CheckHealthAsync(new HealthCheckContext())` if not null. + +`GenerateJsonReportAsync`: +- Use `System.Text.Json.JsonSerializer.Serialize(statusData, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })`. + +`GenerateHtmlFromStatusData`: +- Use `StringBuilder` to generate self-contained HTML. +- Include inline CSS (Bootstrap-like grid, status cards with color-coded left borders). +- Color coding: green (#28a745) for Healthy/Connected, yellow (#ffc107) for Degraded, red (#dc3545) for Unhealthy/Disconnected. +- Operations table with columns: Operation, Count, Success Rate, Avg (ms), Min (ms), Max (ms), P95 (ms). +- `` for auto-refresh. +- Last updated timestamp at the bottom. + +`IsHealthyAsync`: +- Run basic health check, return `result.Status == HealthStatus.Healthy`. + +### 3.3 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" +``` + +## Step 4: Create StatusWebServer + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs` + +Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` + +```csharp +public class StatusWebServer : IDisposable +{ + private static readonly ILogger Logger = Log.ForContext(); + private readonly WebServerConfiguration _configuration; + private readonly StatusReportService _statusReportService; + private HttpListener? _httpListener; + private CancellationTokenSource? _cancellationTokenSource; + private Task? _listenerTask; + private bool _disposed; + + public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) { ... } + + public bool Start() { ... } + public bool Stop() { ... } + public void Dispose() { ... } + + private async Task HandleRequestsAsync(CancellationToken cancellationToken) { ... } + private async Task HandleRequestAsync(HttpListenerContext context) { ... } + private async Task HandleStatusPageAsync(HttpListenerResponse response) { ... } + private async Task HandleStatusApiAsync(HttpListenerResponse response) { ... } + private async Task HandleHealthApiAsync(HttpListenerResponse response) { ... } + private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType) { ... } +} +``` + +### 4.1 Start() + +1. If `!_configuration.Enabled`, log info and return `true`. +2. Create `HttpListener`, add prefix `_configuration.Prefix ?? $"http://+:{_configuration.Port}/"` (ensure trailing `/`). +3. Call `_httpListener.Start()`. +4. Create `_cancellationTokenSource = new CancellationTokenSource()`. +5. Start `_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token))`. +6. On exception, log error and return `false`. + +### 4.2 Stop() + +1. If not enabled or listener is null, return `true`. +2. Cancel `_cancellationTokenSource`. +3. Wait for `_listenerTask` with 5-second timeout. +4. Stop and close `_httpListener`. + +### 4.3 HandleRequestsAsync + +- Loop while not cancelled and listener is listening. +- `await _httpListener.GetContextAsync()` — on success, spawn `Task.Run` to handle. +- Catch `ObjectDisposedException` and `HttpListenerException(995)` as expected shutdown signals. +- On other errors, log and delay 1 second before continuing. + +### 4.4 HandleRequestAsync routing + +| Path (lowered) | Handler | +|---|---| +| `/` | `HandleStatusPageAsync` — calls `_statusReportService.GenerateHtmlReportAsync()`, content type `text/html; charset=utf-8` | +| `/api/status` | `HandleStatusApiAsync` — calls `_statusReportService.GenerateJsonReportAsync()`, content type `application/json; charset=utf-8` | +| `/api/health` | `HandleHealthApiAsync` — calls `_statusReportService.IsHealthyAsync()`, returns `"OK"` (200) or `"UNHEALTHY"` (503) as `text/plain` | +| Non-GET method | Return 405 Method Not Allowed | +| Unknown path | Return 404 Not Found | +| Exception | Return 500 Internal Server Error | + +### 4.5 WriteResponseAsync + +- Set `Content-Type`, add `Cache-Control: no-cache, no-store, must-revalidate`, `Pragma: no-cache`, `Expires: 0`. +- Convert content to UTF-8 bytes, set `ContentLength64`, write to `response.OutputStream`. + +### 4.6 Dispose + +- Guard with `_disposed` flag. Call `Stop()`. Dispose `_cancellationTokenSource` and close `_httpListener`. + +### 4.7 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" +``` + +## Step 5: Wire into LmxProxyService + +**File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` + +This file already exists. Modify the `Start()` method to create and wire the new components. The v2 rebuild should create these fresh, but the wiring pattern follows the same order as the reference. + +### 5.1 Add using directives + +```csharp +using ZB.MOM.WW.LmxProxy.Host.Health; +using ZB.MOM.WW.LmxProxy.Host.Metrics; +using ZB.MOM.WW.LmxProxy.Host.Status; +``` + +### 5.2 Add fields + +```csharp +private PerformanceMetrics? _performanceMetrics; +private HealthCheckService? _healthCheckService; +private DetailedHealthCheckService? _detailedHealthCheckService; +private StatusReportService? _statusReportService; +private StatusWebServer? _statusWebServer; +``` + +### 5.3 In Start(), after SessionManager and SubscriptionManager creation + +```csharp +// Create performance metrics +_performanceMetrics = new PerformanceMetrics(); + +// Create health check services +_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics); +_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient); + +// Create status report service +_statusReportService = new StatusReportService( + _scadaClient, _subscriptionManager, _performanceMetrics, + _healthCheckService, _detailedHealthCheckService); + +// Start status web server +_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService); +if (!_statusWebServer.Start()) +{ + Logger.Warning("Status web server failed to start — continuing without it"); +} +``` + +### 5.4 In Stop(), before gRPC server shutdown + +```csharp +// Stop status web server +_statusWebServer?.Stop(); + +// Dispose performance metrics +_performanceMetrics?.Dispose(); +``` + +### 5.5 Pass _performanceMetrics to ScadaGrpcService constructor + +Ensure `ScadaGrpcService` receives `_performanceMetrics` so it can record timings on each RPC call. The gRPC service should call `_performanceMetrics.BeginOperation("Read")` (etc.) and dispose the timing scope at the end of each RPC handler. + +### 5.6 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" +``` + +## Step 6: Unit Tests + +**Project**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/` + +If this project does not exist yet, create it: + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Host.Tests -o tests/ZB.MOM.WW.LmxProxy.Host.Tests --framework net48" +``` + +**Csproj adjustments** for `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj`: +- `net48` +- `x86` +- `9.0` +- Add `` +- Add `` +- Add `` +- Add `` (for mocking IScadaClient) +- Add `` + +**Also add to solution** in `ZB.MOM.WW.LmxProxy.slnx`: +```xml + + + +``` + +### 6.1 PerformanceMetrics Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics +{ + public class PerformanceMetricsTests + { + [Fact] + public void RecordOperation_TracksCountAndDuration() + // Record 5 operations, verify GetStatistics returns TotalCount=5 + + [Fact] + public void RecordOperation_TracksSuccessAndFailure() + // Record 3 success + 2 failure, verify SuccessRate == 0.6 + + [Fact] + public void GetStatistics_CalculatesP95Correctly() + // Record 100 operations with known durations (1ms through 100ms) + // Verify P95 is approximately 95ms + + [Fact] + public void RollingBuffer_CapsAt1000Samples() + // Record 1500 operations, verify _durations list doesn't exceed 1000 + // (test via GetStatistics behavior — TotalCount is 1500 but percentile computed from 1000) + + [Fact] + public void BeginOperation_RecordsDurationOnDispose() + // Use BeginOperation, await Task.Delay(50), dispose scope + // Verify recorded duration >= 50ms + + [Fact] + public void TimingScope_DefaultsToSuccess() + // BeginOperation + dispose without calling SetSuccess + // Verify SuccessCount == 1 + + [Fact] + public void TimingScope_RespectsSetSuccessFalse() + // BeginOperation, SetSuccess(false), dispose + // Verify SuccessCount == 0, TotalCount == 1 + + [Fact] + public void GetMetrics_ReturnsNullForUnknownOperation() + + [Fact] + public void GetAllMetrics_ReturnsAllTrackedOperations() + } +} +``` + +### 6.2 HealthCheckService Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs` + +Use NSubstitute to mock `IScadaClient`. Create a real `PerformanceMetrics` instance and a real or mock `SubscriptionManager` (depends on Phase 2/3 implementation — if `SubscriptionManager` has an interface, mock it; if not, use the `GetSubscriptionStats()` approach with a concrete instance). + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health +{ + public class HealthCheckServiceTests + { + [Fact] + public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics() + // Mock: IsConnected=true, ConnectionState=Connected + // SubscriptionStats: TotalClients=5, TotalTags=10 + // PerformanceMetrics: record some successes + // Assert: HealthStatus.Healthy + + [Fact] + public async Task ReturnsUnhealthy_WhenNotConnected() + // Mock: IsConnected=false + // Assert: HealthStatus.Unhealthy, description contains "not connected" + + [Fact] + public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent() + // Mock: IsConnected=true + // Record 200 operations with 40% success rate + // Assert: HealthStatus.Degraded + + [Fact] + public async Task ReturnsDegraded_WhenClientCountOver100() + // Mock: IsConnected=true, SubscriptionStats.TotalClients=150 + // Assert: HealthStatus.Degraded + + [Fact] + public async Task DoesNotFlagLowSuccessRate_Under100Operations() + // Record 50 operations with 0% success rate + // Assert: still Healthy (threshold is > 100 total ops) + } + + public class DetailedHealthCheckServiceTests + { + [Fact] + public async Task ReturnsUnhealthy_WhenNotConnected() + + [Fact] + public async Task ReturnsHealthy_WhenTestTagGoodAndRecent() + // Mock ReadAsync returns Good quality with recent timestamp + // Assert: Healthy + + [Fact] + public async Task ReturnsDegraded_WhenTestTagQualityNotGood() + // Mock ReadAsync returns Uncertain quality + // Assert: Degraded + + [Fact] + public async Task ReturnsDegraded_WhenTestTagTimestampStale() + // Mock ReadAsync returns Good quality but timestamp 10 minutes ago + // Assert: Degraded + + [Fact] + public async Task ReturnsDegraded_WhenTestTagReadThrows() + // Mock ReadAsync throws exception + // Assert: Degraded + } +} +``` + +### 6.3 StatusReportService Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status +{ + public class StatusReportServiceTests + { + [Fact] + public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson() + // Verify JSON contains "serviceName", "connection", "isConnected" (camelCase) + + [Fact] + public async Task GenerateHtmlReportAsync_ContainsAutoRefresh() + // Verify HTML contains + + [Fact] + public async Task IsHealthyAsync_ReturnsTrueWhenHealthy() + + [Fact] + public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy() + + [Fact] + public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics() + // Record some operations, verify JSON includes operation names and stats + } +} +``` + +### 6.4 Run tests + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86 --verbosity normal" +``` + +## Step 7: Build Verification + +Run full solution build and tests: + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" +``` + +If the test project is .NET 4.8 x86, you may need: +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx --platform x86 && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86" +``` + +## Completion Criteria + +- [ ] `PerformanceMetrics` class with `OperationMetrics`, `MetricsStatistics`, `ITimingScope` in `src/ZB.MOM.WW.LmxProxy.Host/Metrics/` +- [ ] `HealthCheckService` and `DetailedHealthCheckService` in `src/ZB.MOM.WW.LmxProxy.Host/Health/` +- [ ] `StatusReportService` with data model classes in `src/ZB.MOM.WW.LmxProxy.Host/Status/` +- [ ] `StatusWebServer` with HTML dashboard, JSON status, and health endpoints in `src/ZB.MOM.WW.LmxProxy.Host/Status/` +- [ ] All components wired into `LmxProxyService.Start()` / `Stop()` +- [ ] `ScadaGrpcService` uses `PerformanceMetrics.BeginOperation()` for Read, ReadBatch, Write, WriteBatch RPCs +- [ ] Unit tests for PerformanceMetrics (recording, percentile, rolling buffer, timing scope) +- [ ] Unit tests for HealthCheckService (healthy, unhealthy, degraded transitions) +- [ ] Unit tests for DetailedHealthCheckService (connected, quality, staleness) +- [ ] Unit tests for StatusReportService (JSON format, HTML format, health aggregation) +- [ ] Solution builds without errors: `dotnet build ZB.MOM.WW.LmxProxy.slnx` +- [ ] All tests pass: `dotnet test` diff --git a/lmxproxy/docs/plans/phase-5-client-core.md b/lmxproxy/docs/plans/phase-5-client-core.md new file mode 100644 index 0000000..796ed0c --- /dev/null +++ b/lmxproxy/docs/plans/phase-5-client-core.md @@ -0,0 +1,852 @@ +# Phase 5: Client Core — Implementation Plan + +**Date**: 2026-03-21 +**Prerequisites**: Phase 1 complete and passing (Protocol & Domain Types — `ScadaContracts.cs` with v2 `TypedValue`/`QualityCode` messages, `Quality.cs`, `QualityExtensions.cs`, `Vtq.cs`, `ConnectionState.cs` all exist and cross-stack serialization tests pass) +**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` + +## Guardrails + +1. **Client targets .NET 10, AnyCPU** — use latest C# features freely. The csproj `` is `net10.0`, `latest`. +2. **Code-first gRPC only** — the Client uses `protobuf-net.Grpc` with `[ServiceContract]`/`[DataContract]` attributes. Never reference proto files or `Grpc.Tools`. +3. **No string serialization heuristics** — v2 uses native `TypedValue`. Do not write `double.TryParse`, `bool.TryParse`, or any string-to-value parsing on tag values. +4. **`status_code` is canonical for quality** — `symbolic_name` is derived. Never set `symbolic_name` independently. +5. **Polly v8 API** — the Client csproj already has ``. Use the v8 `ResiliencePipeline` API, not the legacy v7 `IAsyncPolicy` API. +6. **No new NuGet packages** — all needed packages are already in `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj`. +7. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client` +8. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests` +9. **Namespace root**: `ZB.MOM.WW.LmxProxy.Client` + +## Step 1: ClientTlsConfiguration + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs` + +This file already exists with the correct shape. Verify it has all these properties (from Component-Client.md): + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client; + +public class ClientTlsConfiguration +{ + public bool UseTls { get; set; } = false; + public string? ClientCertificatePath { get; set; } + public string? ClientKeyPath { get; set; } + public string? ServerCaCertificatePath { get; set; } + public string? ServerNameOverride { get; set; } + public bool ValidateServerCertificate { get; set; } = true; + public bool AllowSelfSignedCertificates { get; set; } = false; + public bool IgnoreAllCertificateErrors { get; set; } = false; +} +``` + +If it matches, no changes needed. If any properties are missing, add them. + +## Step 2: Security/GrpcChannelFactory + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs` + +This file already exists. Verify the implementation covers: + +1. `CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)` — returns `GrpcChannel`. +2. Creates `SocketsHttpHandler` with `EnableMultipleHttp2Connections = true`. +3. For TLS: sets `SslProtocols = Tls12 | Tls13`, configures `ServerNameOverride` as `TargetHost`, loads client certificate from PEM files for mTLS. +4. Certificate validation callback handles: `IgnoreAllCertificateErrors`, `!ValidateServerCertificate`, custom CA trust store via `ServerCaCertificatePath`, `AllowSelfSignedCertificates`. +5. Static constructor sets `System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport = true` for non-TLS. + +The existing implementation matches. No changes expected unless Phase 1 introduced breaking changes. + +## Step 3: ILmxProxyClient Interface + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs` + +Rewrite for v2 protocol. The key changes from v1: +- `WriteAsync` and `WriteBatchAsync` accept `TypedValue` instead of `object` +- `SubscribeAsync` has an `onStreamError` callback parameter +- `CheckApiKeyAsync` is added +- Return types use v2 domain `Vtq` (which wraps `TypedValue` + `QualityCode`) + +```csharp +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 (range: 1s to 10min). + TimeSpan DefaultTimeout { get; set; } + + /// Connects to the LmxProxy service and establishes a session. + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// Disconnects from the LmxProxy service. + Task DisconnectAsync(); + + /// Returns true if the client has an active session. + Task IsConnectedAsync(); + + /// Reads a single tag value. + Task ReadAsync(string address, CancellationToken cancellationToken = default); + + /// Reads multiple tag values in a single batch. + Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); + + /// Writes a single tag value (native TypedValue — no string heuristics). + Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default); + + /// Writes multiple tag values in a single batch. + Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); + + /// + /// Writes a batch of values, then polls a flag tag until it matches or timeout expires. + /// Returns (writeResults, flagReached, elapsedMs). + /// + Task WriteBatchAndWaitAsync( + IDictionary values, + string flagTag, + TypedValue flagValue, + int timeoutMs = 5000, + int pollIntervalMs = 100, + CancellationToken cancellationToken = default); + + /// Subscribes to tag updates with value and error callbacks. + Task SubscribeAsync( + IEnumerable addresses, + Action onUpdate, + Action? onStreamError = null, + CancellationToken cancellationToken = default); + + /// Validates an API key and returns info. + Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default); + + /// Returns a snapshot of client-side metrics. + Dictionary GetMetrics(); +} +``` + +**Note**: The `TypedValue` class referenced here is from `Domain/ScadaContracts.cs` — it should already have been updated in Phase 1 to use `[DataContract]` with the v2 oneof-style properties (e.g., `BoolValue`, `Int32Value`, `DoubleValue`, `StringValue`, `DatetimeValue`, etc., with a `ValueCase` enum or similar discriminator). + +## Step 4: LmxProxyClient — Main File + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs` + +This is a partial class. The main file contains the constructor, fields, properties, and the Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey methods. + +### 4.1 Fields and Constructor + +```csharp +public partial class LmxProxyClient : ILmxProxyClient +{ + private readonly ILogger _logger; + private readonly string _host; + private readonly int _port; + private readonly string? _apiKey; + private readonly ClientTlsConfiguration? _tlsConfiguration; + private readonly ClientMetrics _metrics = new(); + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private readonly List _activeSubscriptions = []; + private readonly Lock _subscriptionLock = new(); + + private GrpcChannel? _channel; + private IScadaService? _client; + private string _sessionId = string.Empty; + private bool _disposed; + private bool _isConnected; + private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); + private ClientConfiguration? _configuration; + private ResiliencePipeline? _resiliencePipeline; // Polly v8 + private Timer? _keepAliveTimer; + private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30); + + // IsConnected computed property + public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); + + public LmxProxyClient( + string host, int port, string? apiKey, + ClientTlsConfiguration? tlsConfiguration, + ILogger? logger = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + _apiKey = apiKey; + _tlsConfiguration = tlsConfiguration; + _logger = logger ?? NullLogger.Instance; + } + + internal void SetBuilderConfiguration(ClientConfiguration config) + { + _configuration = config; + // Build Polly v8 ResiliencePipeline from config + if (config.MaxRetryAttempts > 0) + { + _resiliencePipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = config.MaxRetryAttempts, + Delay = config.RetryDelay, + BackoffType = DelayBackoffType.Exponential, + ShouldHandle = new PredicateBuilder() + .Handle(ex => + ex.StatusCode == StatusCode.Unavailable || + ex.StatusCode == StatusCode.DeadlineExceeded || + ex.StatusCode == StatusCode.ResourceExhausted || + ex.StatusCode == StatusCode.Aborted), + OnRetry = args => + { + _logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}", + args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message); + return ValueTask.CompletedTask; + } + }) + .Build(); + } + } +} +``` + +### 4.2 ReadAsync + +```csharp +public async Task ReadAsync(string address, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + _metrics.IncrementOperationCount("Read"); + var sw = Stopwatch.StartNew(); + try + { + var request = new ReadRequest { SessionId = _sessionId, Tag = address }; + ReadResponse response = await ExecuteWithRetry( + () => _client!.ReadAsync(request).AsTask(), cancellationToken); + if (!response.Success) + throw new InvalidOperationException($"Read failed: {response.Message}"); + return ConvertVtqMessage(response.Vtq); + } + catch (Exception ex) + { + _metrics.IncrementErrorCount("Read"); + throw; + } + finally + { + sw.Stop(); + _metrics.RecordLatency("Read", sw.ElapsedMilliseconds); + } +} +``` + +### 4.3 ReadBatchAsync + +```csharp +public async Task> ReadBatchAsync( + IEnumerable addresses, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + _metrics.IncrementOperationCount("ReadBatch"); + var sw = Stopwatch.StartNew(); + try + { + var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() }; + ReadBatchResponse response = await ExecuteWithRetry( + () => _client!.ReadBatchAsync(request).AsTask(), cancellationToken); + var result = new Dictionary(); + foreach (var vtqMsg in response.Vtqs) + { + result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg); + } + return result; + } + catch + { + _metrics.IncrementErrorCount("ReadBatch"); + throw; + } + finally + { + sw.Stop(); + _metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds); + } +} +``` + +### 4.4 WriteAsync + +```csharp +public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + _metrics.IncrementOperationCount("Write"); + var sw = Stopwatch.StartNew(); + try + { + var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value }; + WriteResponse response = await ExecuteWithRetry( + () => _client!.WriteAsync(request).AsTask(), cancellationToken); + if (!response.Success) + throw new InvalidOperationException($"Write failed: {response.Message}"); + } + catch + { + _metrics.IncrementErrorCount("Write"); + throw; + } + finally + { + sw.Stop(); + _metrics.RecordLatency("Write", sw.ElapsedMilliseconds); + } +} +``` + +### 4.5 WriteBatchAsync + +```csharp +public async Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + _metrics.IncrementOperationCount("WriteBatch"); + var sw = Stopwatch.StartNew(); + try + { + var request = new WriteBatchRequest + { + SessionId = _sessionId, + Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList() + }; + WriteBatchResponse response = await ExecuteWithRetry( + () => _client!.WriteBatchAsync(request).AsTask(), cancellationToken); + if (!response.Success) + throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); + } + catch + { + _metrics.IncrementErrorCount("WriteBatch"); + throw; + } + finally + { + sw.Stop(); + _metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds); + } +} +``` + +### 4.6 WriteBatchAndWaitAsync + +```csharp +public async Task WriteBatchAndWaitAsync( + IDictionary values, string flagTag, TypedValue flagValue, + int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + var request = new WriteBatchAndWaitRequest + { + SessionId = _sessionId, + Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(), + FlagTag = flagTag, + FlagValue = flagValue, + TimeoutMs = timeoutMs, + PollIntervalMs = pollIntervalMs + }; + return await ExecuteWithRetry( + () => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken); +} +``` + +### 4.7 CheckApiKeyAsync + +```csharp +public async Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) +{ + EnsureConnected(); + var request = new CheckApiKeyRequest { ApiKey = apiKey }; + CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request); + return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message }; +} +``` + +### 4.8 ConvertVtqMessage helper + +This converts the wire `VtqMessage` (v2 with `TypedValue` + `QualityCode`) to the domain `Vtq`: + +```csharp +private static Vtq ConvertVtqMessage(VtqMessage? msg) +{ + if (msg is null) + return new Vtq(null, DateTime.UtcNow, Quality.Bad); + + object? value = ExtractTypedValue(msg.Value); + DateTime timestamp = msg.TimestampUtcTicks > 0 + ? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc) + : DateTime.UtcNow; + Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u); + return new Vtq(value, timestamp, quality); +} + +private static object? ExtractTypedValue(TypedValue? tv) +{ + if (tv is null) return null; + // Switch on whichever oneof-style property is set + // The exact property names depend on the Phase 1 code-first contract design + // e.g., tv.BoolValue, tv.Int32Value, tv.DoubleValue, tv.StringValue, etc. + // Return the native .NET value directly — no string conversions + ... +} +``` + +**Important**: The exact shape of `TypedValue` in code-first contracts depends on Phase 1's implementation. Phase 1 should have defined a discriminator pattern (e.g., `ValueCase` enum or nullable properties with a convention). Adapt `ExtractTypedValue` to whatever pattern was chosen. The key rule: **no string heuristics**. + +### 4.9 ExecuteWithRetry helper + +```csharp +private async Task ExecuteWithRetry(Func> operation, CancellationToken ct) +{ + if (_resiliencePipeline is not null) + { + return await _resiliencePipeline.ExecuteAsync( + async token => await operation(), ct); + } + return await operation(); +} +``` + +### 4.10 EnsureConnected, Dispose, DisposeAsync + +```csharp +private void EnsureConnected() +{ + ObjectDisposedException.ThrowIf(_disposed, this); + if (!IsConnected) + throw new InvalidOperationException("Client is not connected. Call ConnectAsync first."); +} + +public void Dispose() +{ + if (_disposed) return; + _disposed = true; + _keepAliveTimer?.Dispose(); + _channel?.Dispose(); + _connectionLock.Dispose(); +} + +public async ValueTask DisposeAsync() +{ + if (_disposed) return; + try { await DisconnectAsync(); } catch { /* swallow */ } + Dispose(); +} +``` + +### 4.11 IsConnectedAsync + +```csharp +public Task IsConnectedAsync() => Task.FromResult(IsConnected); +``` + +### 4.12 GetMetrics + +```csharp +public Dictionary GetMetrics() => _metrics.GetSnapshot(); +``` + +### 4.13 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 5: LmxProxyClient.Connection + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs` + +Partial class containing `ConnectAsync`, `DisconnectAsync`, keep-alive, `MarkDisconnectedAsync`, `BuildEndpointUri`. + +### 5.1 ConnectAsync + +1. Acquire `_connectionLock`. +2. Throw `ObjectDisposedException` if disposed. +3. Return early if already connected. +4. Build endpoint URI via `BuildEndpointUri()`. +5. Create channel: `GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger)`. +6. Create code-first client: `channel.CreateGrpcService()` (from `ProtoBuf.Grpc.Client`). +7. Send `ConnectRequest` with `ClientId = $"ScadaBridge-{Guid.NewGuid():N}"` and `ApiKey = _apiKey ?? string.Empty`. +8. If `!response.Success`, dispose channel and throw. +9. Store channel, client, sessionId. Set `_isConnected = true`. +10. Call `StartKeepAlive()`. +11. On failure, reset all state and rethrow. +12. Release lock in `finally`. + +### 5.2 DisconnectAsync + +1. Acquire `_connectionLock`. +2. Stop keep-alive. +3. If client and session exist, send `DisconnectRequest`. Swallow exceptions. +4. Clear client, sessionId, isConnected. Dispose channel. +5. Release lock. + +### 5.3 Keep-alive timer + +- `StartKeepAlive()`: creates `Timer` with `_keepAliveInterval` (30s) interval. +- Timer callback: sends `GetConnectionStateRequest`. On failure: stops timer, calls `MarkDisconnectedAsync(ex)`. +- `StopKeepAlive()`: disposes timer, nulls it. + +### 5.4 MarkDisconnectedAsync + +1. If disposed, return. +2. Acquire `_connectionLock`, set `_isConnected = false`, clear client/sessionId, dispose channel. Release lock. +3. Copy and clear `_activeSubscriptions` under `_subscriptionLock`. +4. Dispose each subscription (swallow errors). +5. Log warning with the exception. + +### 5.5 BuildEndpointUri + +```csharp +private Uri BuildEndpointUri() +{ + string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri; +} +``` + +### 5.6 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 6: LmxProxyClient.CodeFirstSubscription + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs` + +Nested class inside `LmxProxyClient` implementing `ISubscription`. + +### 6.1 CodeFirstSubscription class + +```csharp +private class CodeFirstSubscription : ISubscription +{ + private readonly IScadaService _client; + private readonly string _sessionId; + private readonly List _tags; + private readonly Action _onUpdate; + private readonly Action? _onStreamError; + private readonly ILogger _logger; + private readonly Action? _onDispose; + private readonly CancellationTokenSource _cts = new(); + private Task? _processingTask; + private bool _disposed; + private bool _streamErrorFired; +``` + +Constructor takes all of these. `StartAsync` stores `_processingTask = ProcessUpdatesAsync(cancellationToken)`. + +### 6.2 ProcessUpdatesAsync + +```csharp +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 vtqMsg in _client.SubscribeAsync(request, linkedCts.Token)) + { + try + { + Vtq vtq = ConvertVtqMessage(vtqMsg); // static method from outer class + _onUpdate(vtqMsg.Tag, vtq); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag); + } + } + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Subscription cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in subscription processing"); + FireStreamError(ex); + } + finally + { + if (!_disposed) + { + _disposed = true; + _onDispose?.Invoke(this); + } + } +} + +private void FireStreamError(Exception ex) +{ + if (_streamErrorFired) return; + _streamErrorFired = true; + try { _onStreamError?.Invoke(ex); } + catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); } +} +``` + +**Key difference from v1**: The `ConvertVtqMessage` now handles `TypedValue` + `QualityCode` natively instead of parsing strings. Also, `_onStreamError` callback is invoked exactly once on stream termination (per Component-Client.md section 5.1). + +### 6.3 DisposeAsync and Dispose + +`DisposeAsync()`: Cancel CTS, await `_processingTask` (swallow errors), dispose CTS. 5-second timeout guard. + +`Dispose()`: Calls `DisposeAsync()` synchronously with `Task.Wait(TimeSpan.FromSeconds(5))`. + +### 6.4 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 7: LmxProxyClient.ClientMetrics + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs` + +Internal class. Already exists in v1 reference. Rewrite for v2 with p99 support. + +```csharp +internal class ClientMetrics +{ + private readonly ConcurrentDictionary _operationCounts = new(); + private readonly ConcurrentDictionary _errorCounts = new(); + private readonly ConcurrentDictionary> _latencies = new(); + private readonly Lock _latencyLock = new(); + + public void IncrementOperationCount(string operation) { ... } + public void IncrementErrorCount(string operation) { ... } + public void RecordLatency(string operation, long milliseconds) { ... } + public Dictionary GetSnapshot() { ... } +} +``` + +`RecordLatency`: Under `_latencyLock`, add to list. If count > 1000, `RemoveAt(0)`. + +`GetSnapshot`: Returns dictionary with keys `{op}_count`, `{op}_errors`, `{op}_avg_latency_ms`, `{op}_p95_latency_ms`, `{op}_p99_latency_ms`. + +`GetPercentile(List values, int percentile)`: Sort, compute index as `(int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1`, clamp with `Math.Max(0, ...)`. + +## Step 8: LmxProxyClient.ApiKeyInfo + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs` + +Simple DTO returned by `CheckApiKeyAsync`: + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client; + +public partial class LmxProxyClient +{ + /// + /// Result of an API key validation check. + /// + public class ApiKeyInfo + { + public bool IsValid { get; init; } + public string? Role { get; init; } + public string? Description { get; init; } + } +} +``` + +## Step 9: LmxProxyClient.ISubscription + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client; + +public partial class LmxProxyClient +{ + /// + /// Represents an active tag subscription. Dispose to unsubscribe. + /// + public interface ISubscription : IDisposable + { + /// Asynchronous disposal with cancellation support. + Task DisposeAsync(); + } +} +``` + +## Step 10: Unit Tests + +**Project**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` + +Create if not exists: + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.Tests -o tests/ZB.MOM.WW.LmxProxy.Client.Tests --framework net10.0" +``` + +**Csproj** for `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj`: +- `net10.0` +- `` +- `` +- `` +- `` +- `` + +**Add to solution** `ZB.MOM.WW.LmxProxy.slnx`: +```xml + + + +``` + +### 10.1 Connection Lifecycle Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs` + +Mock `IScadaService` using NSubstitute. + +```csharp +public class LmxProxyClientConnectionTests +{ + [Fact] + public async Task ConnectAsync_EstablishesSessionAndStartsKeepAlive() + + [Fact] + public async Task ConnectAsync_ThrowsWhenServerReturnsFailure() + + [Fact] + public async Task DisconnectAsync_SendsDisconnectAndClearsState() + + [Fact] + public async Task IsConnectedAsync_ReturnsFalseBeforeConnect() + + [Fact] + public async Task IsConnectedAsync_ReturnsTrueAfterConnect() + + [Fact] + public async Task KeepAliveFailure_MarksDisconnected() +} +``` + +Note: Testing the keep-alive requires either waiting 30s (too slow) or making the interval configurable for tests. Consider passing the interval as an internal constructor parameter or using a test-only subclass. Alternatively, test `MarkDisconnectedAsync` directly. + +### 10.2 Read/Write Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs` + +```csharp +public class LmxProxyClientReadWriteTests +{ + [Fact] + public async Task ReadAsync_ReturnsVtqFromResponse() + // Mock ReadAsync to return a VtqMessage with TypedValue.DoubleValue = 42.5 + // Verify returned Vtq.Value is 42.5 (double) + + [Fact] + public async Task ReadAsync_ThrowsOnFailureResponse() + + [Fact] + public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs() + + [Fact] + public async Task WriteAsync_SendsTypedValueDirectly() + // Verify the WriteRequest.Value is the TypedValue passed in, not a string + + [Fact] + public async Task WriteBatchAsync_SendsAllItems() + + [Fact] + public async Task WriteBatchAndWaitAsync_ReturnsResponse() +} +``` + +### 10.3 Subscription Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs` + +```csharp +public class LmxProxyClientSubscriptionTests +{ + [Fact] + public async Task SubscribeAsync_InvokesCallbackForEachUpdate() + + [Fact] + public async Task SubscribeAsync_InvokesStreamErrorOnFailure() + + [Fact] + public async Task SubscribeAsync_DisposeStopsProcessing() +} +``` + +### 10.4 TypedValue Conversion Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs` + +```csharp +public class TypedValueConversionTests +{ + [Fact] public void ConvertVtqMessage_ExtractsBoolValue() + [Fact] public void ConvertVtqMessage_ExtractsInt32Value() + [Fact] public void ConvertVtqMessage_ExtractsInt64Value() + [Fact] public void ConvertVtqMessage_ExtractsFloatValue() + [Fact] public void ConvertVtqMessage_ExtractsDoubleValue() + [Fact] public void ConvertVtqMessage_ExtractsStringValue() + [Fact] public void ConvertVtqMessage_ExtractsDateTimeValue() + [Fact] public void ConvertVtqMessage_HandlesNullTypedValue() + [Fact] public void ConvertVtqMessage_HandlesNullMessage() + [Fact] public void ConvertVtqMessage_MapsQualityCodeCorrectly() + [Fact] public void ConvertVtqMessage_GoodQualityCode() + [Fact] public void ConvertVtqMessage_BadQualityCode() + [Fact] public void ConvertVtqMessage_UncertainQualityCode() +} +``` + +### 10.5 Metrics Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs` + +```csharp +public class ClientMetricsTests +{ + [Fact] public void IncrementOperationCount_Increments() + [Fact] public void IncrementErrorCount_Increments() + [Fact] public void RecordLatency_StoresValues() + [Fact] public void RollingBuffer_CapsAt1000() + [Fact] public void GetSnapshot_IncludesP95AndP99() +} +``` + +### 10.6 Run tests + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal" +``` + +## Step 11: Build Verification + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" +``` + +## Completion Criteria + +- [ ] `ILmxProxyClient` interface updated for v2 (TypedValue parameters, onStreamError callback, CheckApiKeyAsync) +- [ ] `LmxProxyClient.cs` — main file with Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey using v2 TypedValue +- [ ] `LmxProxyClient.Connection.cs` — ConnectAsync, DisconnectAsync, keep-alive (30s), MarkDisconnectedAsync +- [ ] `LmxProxyClient.CodeFirstSubscription.cs` — IAsyncEnumerable processing, onStreamError callback, 5s dispose timeout +- [ ] `LmxProxyClient.ClientMetrics.cs` — per-op counts/errors/latency, 1000-sample buffer, p95/p99 +- [ ] `LmxProxyClient.ApiKeyInfo.cs` — simple DTO +- [ ] `LmxProxyClient.ISubscription.cs` — IDisposable + DisposeAsync +- [ ] `ClientTlsConfiguration.cs` — all properties present +- [ ] `Security/GrpcChannelFactory.cs` — TLS 1.2/1.3, cert validation, custom CA, self-signed support +- [ ] No string serialization heuristics anywhere in Client code +- [ ] ConvertVtqMessage extracts native TypedValue without parsing +- [ ] Polly v8 ResiliencePipeline for retry (not v7 IAsyncPolicy) +- [ ] All unit tests pass +- [ ] Solution builds cleanly diff --git a/lmxproxy/docs/plans/phase-6-client-extras.md b/lmxproxy/docs/plans/phase-6-client-extras.md new file mode 100644 index 0000000..561997c --- /dev/null +++ b/lmxproxy/docs/plans/phase-6-client-extras.md @@ -0,0 +1,815 @@ +# Phase 6: Client Extras — Implementation Plan + +**Date**: 2026-03-21 +**Prerequisites**: Phase 5 complete and passing (Client Core — `ILmxProxyClient`, `LmxProxyClient` partial classes, `ClientMetrics`, `ISubscription`, `ApiKeyInfo` all functional with unit tests passing) +**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` + +## Guardrails + +1. **Client targets .NET 10, AnyCPU** — latest C# features permitted. +2. **Polly v8 API** — `ResiliencePipeline`, `ResiliencePipelineBuilder`, `RetryStrategyOptions`. Do NOT use Polly v7 `IAsyncPolicy`, `Policy.Handle<>().WaitAndRetryAsync(...)`. +3. **Builder default port is 50051** (per design doc section 11 — resolved conflict). +4. **No new NuGet packages** — `Polly 8.5.2`, `Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Binder 10.0.0`, `Microsoft.Extensions.Logging.Abstractions 10.0.0` are already in the csproj. +5. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client` +6. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests` + +## Step 1: LmxProxyClientBuilder + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs` + +Rewrite the builder for v2. Key changes from v1: +- Default port changes from `5050` to `50051` +- Retry uses Polly v8 `ResiliencePipeline` (built in `SetBuilderConfiguration`) +- `WithCorrelationIdHeader` support + +### 1.1 Builder fields + +```csharp +public class LmxProxyClientBuilder +{ + private string? _host; + private int _port = 50051; // CHANGED from 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; +``` + +### 1.2 Fluent methods + +Each method returns `this` for chaining. Validation at call site: + +| Method | Default | Validation | +|---|---|---| +| `WithHost(string host)` | Required | `!string.IsNullOrWhiteSpace(host)` | +| `WithPort(int port)` | 50051 | 1-65535 | +| `WithApiKey(string? apiKey)` | null | none | +| `WithLogger(ILogger logger)` | NullLogger | `!= null` | +| `WithTimeout(TimeSpan timeout)` | 30s | `> TimeSpan.Zero && <= TimeSpan.FromMinutes(10)` | +| `WithSslCredentials(string? certificatePath)` | disabled | creates/updates `_tlsConfiguration` with `UseTls=true` | +| `WithTlsConfiguration(ClientTlsConfiguration config)` | null | `!= null` | +| `WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)` | 3, 1s | `maxAttempts > 0`, `retryDelay > TimeSpan.Zero` | +| `WithMetrics()` | disabled | sets `_enableMetrics = true` | +| `WithCorrelationIdHeader(string headerName)` | null | `!string.IsNullOrEmpty` | + +### 1.3 Build() + +```csharp +public LmxProxyClient Build() +{ + if (string.IsNullOrWhiteSpace(_host)) + throw new InvalidOperationException("Host must be specified. Call WithHost() before Build()."); + + ValidateTlsConfiguration(); + + var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger) + { + DefaultTimeout = _defaultTimeout + }; + + client.SetBuilderConfiguration(new ClientConfiguration + { + MaxRetryAttempts = _maxRetryAttempts, + RetryDelay = _retryDelay, + EnableMetrics = _enableMetrics, + CorrelationIdHeader = _correlationIdHeader + }); + + return client; +} +``` + +### 1.4 ValidateTlsConfiguration + +If `_tlsConfiguration?.UseTls == true`: +- If `ServerCaCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`. +- If `ClientCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`. +- If `ClientKeyPath` is set and file doesn't exist → throw `FileNotFoundException`. + +### 1.5 Polly v8 ResiliencePipeline setup (in LmxProxyClient.SetBuilderConfiguration) + +This was defined in Step 4 of Phase 5. Verify it uses: + +```csharp +using Polly; +using Polly.Retry; +using Grpc.Core; + +_resiliencePipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = config.MaxRetryAttempts, + Delay = config.RetryDelay, + BackoffType = DelayBackoffType.Exponential, + ShouldHandle = new PredicateBuilder() + .Handle(ex => + ex.StatusCode == StatusCode.Unavailable || + ex.StatusCode == StatusCode.DeadlineExceeded || + ex.StatusCode == StatusCode.ResourceExhausted || + ex.StatusCode == StatusCode.Aborted), + OnRetry = args => + { + _logger.LogWarning( + "Retry {Attempt}/{Max} after {Delay}ms — {Error}", + args.AttemptNumber, config.MaxRetryAttempts, + args.RetryDelay.TotalMilliseconds, + args.Outcome.Exception?.Message ?? "unknown"); + return ValueTask.CompletedTask; + } + }) + .Build(); +``` + +Backoff sequence: `retryDelay * 2^(attempt-1)` → 1s, 2s, 4s for defaults. + +### 1.6 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 2: ClientConfiguration + +**File**: This is already defined in `LmxProxyClientBuilder.cs` (at the bottom of the file, as an `internal class`). Verify it contains: + +```csharp +internal class ClientConfiguration +{ + public int MaxRetryAttempts { get; set; } + public TimeSpan RetryDelay { get; set; } + public bool EnableMetrics { get; set; } + public string? CorrelationIdHeader { get; set; } +} +``` + +No changes needed if it matches. + +## Step 3: ILmxProxyClientFactory + LmxProxyClientFactory + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs` + +### 3.1 Interface + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client; + +public interface ILmxProxyClientFactory +{ + LmxProxyClient CreateClient(); + LmxProxyClient CreateClient(string configName); + LmxProxyClient CreateClient(Action builderAction); +} +``` + +### 3.2 Implementation + +```csharp +public class LmxProxyClientFactory : ILmxProxyClientFactory +{ + private readonly IConfiguration _configuration; + + public LmxProxyClientFactory(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public LmxProxyClient CreateClient() => CreateClient("LmxProxy"); + + public LmxProxyClient CreateClient(string configName) + { + IConfigurationSection section = _configuration.GetSection(configName); + var options = new LmxProxyClientOptions(); + section.Bind(options); + return BuildFromOptions(options); + } + + public LmxProxyClient CreateClient(Action builderAction) + { + var builder = new LmxProxyClientBuilder(); + builderAction(builder); + return builder.Build(); + } + + private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options) + { + var builder = new LmxProxyClientBuilder() + .WithHost(options.Host) + .WithPort(options.Port) + .WithTimeout(options.Timeout) + .WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay); + + if (!string.IsNullOrEmpty(options.ApiKey)) + builder.WithApiKey(options.ApiKey); + + if (options.EnableMetrics) + builder.WithMetrics(); + + if (!string.IsNullOrEmpty(options.CorrelationIdHeader)) + builder.WithCorrelationIdHeader(options.CorrelationIdHeader); + + if (options.UseSsl) + { + builder.WithTlsConfiguration(new ClientTlsConfiguration + { + UseTls = true, + ServerCaCertificatePath = options.CertificatePath + }); + } + + return builder.Build(); + } +} +``` + +### 3.3 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 4: ServiceCollectionExtensions + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs` + +### 4.1 Options classes + +Define at the bottom of the file or in a separate `LmxProxyClientOptions.cs`: + +```csharp +public class LmxProxyClientOptions +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 50051; // CHANGED from 5050 + public string? ApiKey { get; set; } + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + public bool UseSsl { get; set; } + public string? CertificatePath { get; set; } + public bool EnableMetrics { get; set; } + public string? CorrelationIdHeader { get; set; } + public RetryOptions Retry { get; set; } = new(); +} + +public class RetryOptions +{ + public int MaxAttempts { get; set; } = 3; + public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1); +} +``` + +### 4.2 Extension methods + +```csharp +public static class ServiceCollectionExtensions +{ + /// Registers a singleton ILmxProxyClient from the "LmxProxy" config section. + public static IServiceCollection AddLmxProxyClient( + this IServiceCollection services, IConfiguration configuration) + { + return services.AddLmxProxyClient(configuration, "LmxProxy"); + } + + /// Registers a singleton ILmxProxyClient from a named config section. + public static IServiceCollection AddLmxProxyClient( + this IServiceCollection services, IConfiguration configuration, string sectionName) + { + services.AddSingleton( + sp => new LmxProxyClientFactory(configuration)); + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return factory.CreateClient(sectionName); + }); + return services; + } + + /// Registers a singleton ILmxProxyClient via builder action. + public static IServiceCollection AddLmxProxyClient( + this IServiceCollection services, Action configure) + { + services.AddSingleton(sp => + { + var builder = new LmxProxyClientBuilder(); + configure(builder); + return builder.Build(); + }); + return services; + } + + /// Registers a scoped ILmxProxyClient from the "LmxProxy" config section. + public static IServiceCollection AddScopedLmxProxyClient( + this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton( + sp => new LmxProxyClientFactory(configuration)); + services.AddScoped(sp => + { + var factory = sp.GetRequiredService(); + return factory.CreateClient(); + }); + return services; + } + + /// Registers a keyed singleton ILmxProxyClient. + public static IServiceCollection AddNamedLmxProxyClient( + this IServiceCollection services, string name, Action configure) + { + services.AddKeyedSingleton(name, (sp, key) => + { + var builder = new LmxProxyClientBuilder(); + configure(builder); + return builder.Build(); + }); + return services; + } +} +``` + +### 4.3 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 5: StreamingExtensions + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs` + +### 5.1 ReadStreamAsync + +```csharp +public static class StreamingExtensions +{ + /// + /// Reads multiple tags as an async stream in batches. + /// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors. + /// + 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)); + + var batch = new List(batchSize); + int consecutiveErrors = 0; + const int maxConsecutiveErrors = 3; + const int maxRetries = 2; + + foreach (string address in addresses) + { + cancellationToken.ThrowIfCancellationRequested(); + batch.Add(address); + + if (batch.Count >= batchSize) + { + await foreach (var kvp in ReadBatchWithRetry( + client, batch, maxRetries, cancellationToken)) + { + consecutiveErrors = 0; + yield return kvp; + } + // If we get here without yielding, it was an error + // (handled inside ReadBatchWithRetry) + batch.Clear(); + } + } + + // Process remaining + if (batch.Count > 0) + { + await foreach (var kvp in ReadBatchWithRetry( + client, batch, maxRetries, cancellationToken)) + { + yield return kvp; + } + } + } + + private static async IAsyncEnumerable> ReadBatchWithRetry( + ILmxProxyClient client, + List batch, + int maxRetries, + [EnumeratorCancellation] CancellationToken ct) + { + int retries = 0; + while (retries <= maxRetries) + { + IDictionary? results = null; + try + { + results = await client.ReadBatchAsync(batch, ct); + } + catch when (retries < maxRetries) + { + retries++; + continue; + } + + if (results is not null) + { + foreach (var kvp in results) + yield return kvp; + yield break; + } + retries++; + } + } +``` + +### 5.2 WriteStreamAsync + +```csharp + /// + /// Writes values from an async enumerable in batches. Returns total count 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)); + + var batch = new Dictionary(batchSize); + int totalWritten = 0; + + await foreach (var kvp in values.WithCancellation(cancellationToken)) + { + batch[kvp.Key] = kvp.Value; + + if (batch.Count >= batchSize) + { + await client.WriteBatchAsync(batch, cancellationToken); + totalWritten += batch.Count; + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await client.WriteBatchAsync(batch, cancellationToken); + totalWritten += batch.Count; + } + + return totalWritten; + } +``` + +### 5.3 ProcessInParallelAsync + +```csharp + /// + /// Processes items in parallel with a configurable max concurrency (default 4). + /// + public static async Task ProcessInParallelAsync( + this IAsyncEnumerable source, + Func processor, + int maxConcurrency = 4, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(processor); + if (maxConcurrency <= 0) + throw new ArgumentOutOfRangeException(nameof(maxConcurrency)); + + using var semaphore = new SemaphoreSlim(maxConcurrency); + var tasks = new List(); + + await foreach (T item in source.WithCancellation(cancellationToken)) + { + await semaphore.WaitAsync(cancellationToken); + + tasks.Add(Task.Run(async () => + { + try + { + await processor(item, cancellationToken); + } + finally + { + semaphore.Release(); + } + }, cancellationToken)); + } + + await Task.WhenAll(tasks); + } +``` + +### 5.4 SubscribeStreamAsync + +```csharp + /// + /// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels. + /// + public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync( + this ILmxProxyClient client, + IEnumerable addresses, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(addresses); + + var channel = Channel.CreateBounded<(string, Vtq)>( + new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + ISubscription? subscription = null; + try + { + subscription = await client.SubscribeAsync( + addresses, + (tag, vtq) => + { + channel.Writer.TryWrite((tag, vtq)); + }, + ex => + { + channel.Writer.TryComplete(ex); + }, + cancellationToken); + + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return item; + } + } + finally + { + subscription?.Dispose(); + channel.Writer.TryComplete(); + } + } +} +``` + +### 5.5 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 6: Properties/AssemblyInfo.cs + +**File**: `src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs` + +Create this file if it doesn't already exist: + +```csharp +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")] +``` + +This allows the test project to access `internal` types like `ClientMetrics` and `ClientConfiguration`. + +### 6.1 Verify build + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" +``` + +## Step 7: Unit Tests + +Add tests to the existing `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` project (created in Phase 5). + +### 7.1 Builder Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs` + +```csharp +public class LmxProxyClientBuilderTests +{ + [Fact] + public void Build_ThrowsWhenHostNotSet() + { + var builder = new LmxProxyClientBuilder(); + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void Build_DefaultPort_Is50051() + { + var client = new LmxProxyClientBuilder() + .WithHost("localhost") + .Build(); + // Verify via reflection or by checking connection attempt URI + Assert.NotNull(client); + } + + [Fact] + public void WithPort_ThrowsOnZero() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithPort(0)); + } + + [Fact] + public void WithPort_ThrowsOn65536() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithPort(65536)); + } + + [Fact] + public void WithTimeout_ThrowsOnNegative() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WithTimeout_ThrowsOver10Minutes() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11))); + } + + [Fact] + public void WithRetryPolicy_ThrowsOnZeroAttempts() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void WithRetryPolicy_ThrowsOnZeroDelay() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero)); + } + + [Fact] + public void Build_WithAllOptions_Succeeds() + { + var client = new LmxProxyClientBuilder() + .WithHost("10.100.0.48") + .WithPort(50051) + .WithApiKey("test-key") + .WithTimeout(TimeSpan.FromSeconds(15)) + .WithRetryPolicy(5, TimeSpan.FromSeconds(2)) + .WithMetrics() + .WithCorrelationIdHeader("X-Correlation-ID") + .Build(); + Assert.NotNull(client); + } + + [Fact] + public void Build_WithTls_ValidatesCertificatePaths() + { + var builder = new LmxProxyClientBuilder() + .WithHost("localhost") + .WithTlsConfiguration(new ClientTlsConfiguration + { + UseTls = true, + ServerCaCertificatePath = "/nonexistent/cert.pem" + }); + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void WithHost_ThrowsOnNull() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithHost(null!)); + } + + [Fact] + public void WithHost_ThrowsOnEmpty() + { + Assert.Throws(() => + new LmxProxyClientBuilder().WithHost("")); + } +} +``` + +### 7.2 Factory Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs` + +```csharp +public class LmxProxyClientFactoryTests +{ + [Fact] + public void CreateClient_BindsFromConfiguration() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["LmxProxy:Host"] = "10.100.0.48", + ["LmxProxy:Port"] = "50052", + ["LmxProxy:ApiKey"] = "test-key", + ["LmxProxy:Retry:MaxAttempts"] = "5", + ["LmxProxy:Retry:Delay"] = "00:00:02", + }) + .Build(); + + var factory = new LmxProxyClientFactory(config); + var client = factory.CreateClient(); + Assert.NotNull(client); + } + + [Fact] + public void CreateClient_NamedSection() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["MyProxy:Host"] = "10.100.0.48", + ["MyProxy:Port"] = "50052", + }) + .Build(); + + var factory = new LmxProxyClientFactory(config); + var client = factory.CreateClient("MyProxy"); + Assert.NotNull(client); + } + + [Fact] + public void CreateClient_BuilderAction() + { + var config = new ConfigurationBuilder().Build(); + var factory = new LmxProxyClientFactory(config); + var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051)); + Assert.NotNull(client); + } +} +``` + +### 7.3 StreamingExtensions Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs` + +```csharp +public class StreamingExtensionsTests +{ + [Fact] + public async Task ReadStreamAsync_BatchesCorrectly() + // Create mock client, provide 250 addresses with batchSize=100 + // Verify ReadBatchAsync called 3 times (100, 100, 50) + + [Fact] + public async Task ReadStreamAsync_RetriesOnError() + // Mock first ReadBatchAsync to throw, second to succeed + // Verify results returned from second attempt + + [Fact] + public async Task WriteStreamAsync_BatchesAndReturnsCount() + // Provide async enumerable of 250 items, batchSize=100 + // Verify WriteBatchAsync called 3 times, total returned = 250 + + [Fact] + public async Task ProcessInParallelAsync_RespectsMaxConcurrency() + // Track concurrent count with SemaphoreSlim + // maxConcurrency=2, verify never exceeds 2 concurrent calls + + [Fact] + public async Task SubscribeStreamAsync_YieldsFromChannel() + // Mock SubscribeAsync to invoke onUpdate callback with test values + // Verify IAsyncEnumerable yields matching items +} +``` + +### 7.4 Run all tests + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal" +``` + +## Step 8: Build Verification + +Run full solution build and all tests: + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" +``` + +## Completion Criteria + +- [ ] `LmxProxyClientBuilder` with default port 50051, Polly v8 wiring, all fluent methods, TLS validation +- [ ] `ClientConfiguration` internal record with retry, metrics, correlation header fields +- [ ] `ILmxProxyClientFactory` + `LmxProxyClientFactory` with 3 `CreateClient` overloads +- [ ] `ServiceCollectionExtensions` with `AddLmxProxyClient` (3 overloads), `AddScopedLmxProxyClient`, `AddNamedLmxProxyClient` +- [ ] `LmxProxyClientOptions` + `RetryOptions` configuration classes +- [ ] `StreamingExtensions` with `ReadStreamAsync` (batched, 2 retries, 3 consecutive error abort), `WriteStreamAsync` (batched), `ProcessInParallelAsync` (SemaphoreSlim, max 4), `SubscribeStreamAsync` (Channel-based IAsyncEnumerable) +- [ ] `Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for test project +- [ ] Builder tests: validation, defaults, Polly pipeline wiring, TLS cert validation +- [ ] Factory tests: config binding from IConfiguration, named sections, builder action +- [ ] StreamingExtensions tests: batching, error recovery, parallel throttling, subscription streaming +- [ ] Solution builds cleanly +- [ ] All tests pass diff --git a/lmxproxy/docs/plans/phase-7-integration-deployment.md b/lmxproxy/docs/plans/phase-7-integration-deployment.md new file mode 100644 index 0000000..42ead77 --- /dev/null +++ b/lmxproxy/docs/plans/phase-7-integration-deployment.md @@ -0,0 +1,793 @@ +# Phase 7: Integration Tests & Deployment — Implementation Plan + +**Date**: 2026-03-21 +**Prerequisites**: Phase 4 (Host complete) and Phase 6 (Client complete) both passing. All unit tests green. +**Working Directory (Mac)**: `/Users/dohertj2/Desktop/scadalink-design/lmxproxy` +**Working Directory (windev)**: `C:\src\lmxproxy` +**windev SSH**: `ssh windev` (alias configured in `~/.ssh/config`, passwordless ed25519, user `dohertj2`) + +## Guardrails + +1. **Never stop the v1 service until v2 is verified** — deploy v2 on alternate ports first. +2. **Take a Veeam backup before cutover** — provides rollback point. +3. **Integration tests run from Mac against windev** — they use `Grpc.Net.Client` which is cross-platform. +4. **All integration tests must pass before cutover**. +5. **API keys**: The existing `apikeys.json` on windev is the source of truth for valid keys. Read it to get test keys. +6. **Real MxAccess tags**: Use tags from the JoeAppEngine namespace which is the live AVEVA System Platform instance on windev. + +## Step 1: Build Host on windev + +### 1.1 Pull latest code + +```bash +ssh windev "cd C:\src\lmxproxy && git pull" +``` + +If the repo doesn't exist on windev yet: + +```bash +ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy" +``` + +### 1.2 Publish Host binary + +```bash +ssh windev "cd C:\src\lmxproxy && dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2\" +``` + +**Expected output**: `C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe` plus dependencies. + +### 1.3 Create v2 appsettings.json + +Create `C:\publish-v2\appsettings.json` configured for testing on alternate ports: + +```bash +ssh windev "powershell -Command \"@' +{ + \"GrpcPort\": 50052, + \"ApiKeyConfigFile\": \"apikeys.json\", + \"Connection\": { + \"MonitorIntervalSeconds\": 5, + \"ConnectionTimeoutSeconds\": 30, + \"ReadTimeoutSeconds\": 5, + \"WriteTimeoutSeconds\": 5, + \"MaxConcurrentOperations\": 10, + \"AutoReconnect\": true + }, + \"Subscription\": { + \"ChannelCapacity\": 1000, + \"ChannelFullMode\": \"DropOldest\" + }, + \"Tls\": { + \"Enabled\": false + }, + \"WebServer\": { + \"Enabled\": true, + \"Port\": 8081 + }, + \"Serilog\": { + \"MinimumLevel\": { + \"Default\": \"Information\", + \"Override\": { + \"Microsoft\": \"Warning\", + \"System\": \"Warning\", + \"Grpc\": \"Information\" + } + }, + \"WriteTo\": [ + { \"Name\": \"Console\" }, + { + \"Name\": \"File\", + \"Args\": { + \"path\": \"logs/lmxproxy-v2-.txt\", + \"rollingInterval\": \"Day\", + \"retainedFileCountLimit\": 30 + } + } + ] + } +} +'@ | Set-Content -Path 'C:\publish-v2\appsettings.json' -Encoding UTF8\"" +``` + +**Key differences from production config**: gRPC port is 50052 (not 50051), web port is 8081 (not 8080), log file prefix is `lmxproxy-v2-`. + +### 1.4 Copy apikeys.json + +If v2 should use the same API keys as v1: + +```bash +ssh windev "copy C:\publish\apikeys.json C:\publish-v2\apikeys.json" +``` + +If `C:\publish\apikeys.json` doesn't exist (the v2 service will auto-generate one on first start): + +```bash +ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate" +``` + +### 1.5 Verify the publish directory + +```bash +ssh windev "dir C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe && dir C:\publish-v2\appsettings.json" +``` + +## Step 2: Deploy v2 Host Service + +### 2.1 Install as a separate Topshelf service + +The v2 service runs alongside v1 on different ports. Install with a distinct service name: + +```bash +ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\" -displayname \"SCADA Bridge LMX Proxy V2\" -description \"LmxProxy v2 gRPC service (test deployment)\" --autostart" +``` + +### 2.2 Start the v2 service + +```bash +ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2" +``` + +### 2.3 Wait 10 seconds for startup, then verify + +```bash +ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host.V2" +``` + +Expected: `STATE: 4 RUNNING`. + +### 2.4 Verify status page + +From Mac, use curl to check the v2 status page: + +```bash +curl -s http://10.100.0.48:8081/ | head -20 +``` + +Expected: HTML containing "LmxProxy Status Dashboard". + +```bash +curl -s http://10.100.0.48:8081/api/health +``` + +Expected: `OK` with HTTP 200. + +```bash +curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool | head -30 +``` + +Expected: JSON with `serviceName`, `connection.isConnected: true`, version info. + +### 2.5 Verify MxAccess connected + +The status page should show `MxAccess Connection: Connected`. If it shows `Disconnected`, check the logs: + +```bash +ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\"" +``` + +### 2.6 Read the apikeys.json to get test keys + +```bash +ssh windev "type C:\publish-v2\apikeys.json" +``` + +Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure: + +```json +{ + "Keys": [ + { "Key": "abc123...", "Role": "ReadWrite", "Description": "Default ReadWrite key" }, + { "Key": "def456...", "Role": "ReadOnly", "Description": "Default ReadOnly key" } + ] +} +``` + +## Step 3: Create Integration Test Project + +### 3.1 Create project + +On windev (or Mac — the test project is .NET 10 and cross-platform): + +```bash +cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy +dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.IntegrationTests -o tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --framework net10.0 +``` + +### 3.2 Configure csproj + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj` + +```xml + + + + net10.0 + latest + enable + false + + + + + + + + + + + + + + + + + PreserveNewest + + + + +``` + +### 3.3 Add to solution + +Edit `ZB.MOM.WW.LmxProxy.slnx`: + +```xml + + + + + + + + + + + +``` + +### 3.4 Create test configuration + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json` + +```json +{ + "LmxProxy": { + "Host": "10.100.0.48", + "Port": 50052, + "ReadWriteApiKey": "REPLACE_WITH_ACTUAL_KEY", + "ReadOnlyApiKey": "REPLACE_WITH_ACTUAL_KEY", + "InvalidApiKey": "invalid-key-that-does-not-exist" + } +} +``` + +**IMPORTANT**: After reading the actual `apikeys.json` from windev in Step 2.6, replace the placeholder values with the real keys. + +### 3.5 Create test base class + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs` + +```csharp +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.LmxProxy.Client; + +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public abstract class IntegrationTestBase : IAsyncLifetime +{ + protected IConfiguration Configuration { get; } + protected string Host { get; } + protected int Port { get; } + protected string ReadWriteApiKey { get; } + protected string ReadOnlyApiKey { get; } + protected string InvalidApiKey { get; } + protected LmxProxyClient? Client { get; set; } + + protected IntegrationTestBase() + { + Configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.test.json") + .Build(); + + var section = Configuration.GetSection("LmxProxy"); + Host = section["Host"] ?? "10.100.0.48"; + Port = int.Parse(section["Port"] ?? "50052"); + ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured"); + ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured"); + InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key"; + } + + protected LmxProxyClient CreateClient(string? apiKey = null) + { + return new LmxProxyClientBuilder() + .WithHost(Host) + .WithPort(Port) + .WithApiKey(apiKey ?? ReadWriteApiKey) + .WithTimeout(TimeSpan.FromSeconds(10)) + .WithRetryPolicy(2, TimeSpan.FromSeconds(1)) + .WithMetrics() + .Build(); + } + + public virtual async Task InitializeAsync() + { + Client = CreateClient(); + await Client.ConnectAsync(); + } + + public virtual async Task DisposeAsync() + { + if (Client is not null) + { + await Client.DisconnectAsync(); + Client.Dispose(); + } + } +} +``` + +## Step 4: Integration Test Scenarios + +### 4.1 Connection Lifecycle + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class ConnectionTests : IntegrationTestBase +{ + [Fact] + public async Task ConnectAndDisconnect_Succeeds() + { + // Client is connected in InitializeAsync + Assert.True(await Client!.IsConnectedAsync()); + await Client.DisconnectAsync(); + Assert.False(await Client.IsConnectedAsync()); + } + + [Fact] + public async Task ConnectWithInvalidApiKey_Fails() + { + using var badClient = CreateClient(InvalidApiKey); + // Expect RpcException with StatusCode.Unauthenticated + var ex = await Assert.ThrowsAsync( + () => badClient.ConnectAsync()); + Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode); + } + + [Fact] + public async Task DoubleConnect_IsIdempotent() + { + await Client!.ConnectAsync(); // Already connected — should be no-op + Assert.True(await Client.IsConnectedAsync()); + } +} +``` + +### 4.2 Read Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class ReadTests : IntegrationTestBase +{ + [Fact] + public async Task Read_StringTag_ReturnsStringValue() + { + // JoeAppEngine.Area is a string attribute that should return "JoeDev" + var vtq = await Client!.ReadAsync("JoeAppEngine.Area"); + Assert.NotNull(vtq.Value); + Assert.IsType(vtq.Value); + Assert.Equal("JoeDev", vtq.Value); + // Quality should be Good (check via QualityExtensions.IsGood if available, + // or check vtq.Quality == Quality.Good) + } + + [Fact] + public async Task Read_WritableTag_ReturnsTypedValue() + { + // JoeAppEngine.BTCS is a writable tag + var vtq = await Client!.ReadAsync("JoeAppEngine.BTCS"); + Assert.NotNull(vtq.Value); + // Verify timestamp is recent (within last hour) + Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1)); + } + + [Fact] + public async Task ReadBatch_MultiplesTags_ReturnsDictionary() + { + var tags = new[] { "JoeAppEngine.Area", "JoeAppEngine.BTCS" }; + var results = await Client!.ReadBatchAsync(tags); + Assert.Equal(2, results.Count); + Assert.True(results.ContainsKey("JoeAppEngine.Area")); + Assert.True(results.ContainsKey("JoeAppEngine.BTCS")); + } + + [Fact] + public async Task Read_NonexistentTag_ReturnsBadQuality() + { + // Reading a tag that doesn't exist should return Bad quality + // (or throw — depends on Host implementation. Adjust assertion accordingly.) + var vtq = await Client!.ReadAsync("NonExistent.Tag.12345"); + // If the Host returns success=false, ReadAsync will throw. + // If it returns success=true with bad quality, check quality. + // Adjust based on actual behavior. + } +} +``` + +### 4.3 Write Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs` + +```csharp +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class WriteTests : IntegrationTestBase +{ + [Fact] + public async Task WriteAndReadBack_StringValue() + { + string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}"; + // Write to a writable string tag + await Client!.WriteAsync("JoeAppEngine.BTCS", + new TypedValue { StringValue = testValue }); + + // Read back and verify + await Task.Delay(500); // Allow time for write to propagate + var vtq = await Client.ReadAsync("JoeAppEngine.BTCS"); + Assert.Equal(testValue, vtq.Value); + } + + [Fact] + public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied() + { + using var readOnlyClient = CreateClient(ReadOnlyApiKey); + await readOnlyClient.ConnectAsync(); + + var ex = await Assert.ThrowsAsync( + () => readOnlyClient.WriteAsync("JoeAppEngine.BTCS", + new TypedValue { StringValue = "should-fail" })); + Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode); + } +} +``` + +### 4.4 Subscribe Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class SubscribeTests : IntegrationTestBase +{ + [Fact] + public async Task Subscribe_ReceivesUpdates() + { + var received = new List<(string Tag, Vtq Vtq)>(); + var receivedEvent = new TaskCompletionSource(); + + var subscription = await Client!.SubscribeAsync( + new[] { "JoeAppEngine.Scheduler.ScanTime" }, + (tag, vtq) => + { + received.Add((tag, vtq)); + if (received.Count >= 3) + receivedEvent.TrySetResult(true); + }, + ex => receivedEvent.TrySetException(ex)); + + // Wait up to 30 seconds for at least 3 updates + var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30))); + subscription.Dispose(); + + Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}"); + + // Verify the VTQ has correct structure + var first = received[0]; + Assert.Equal("JoeAppEngine.Scheduler.ScanTime", first.Tag); + Assert.NotNull(first.Vtq.Value); + // ScanTime should be a DateTime value + Assert.True(first.Vtq.Timestamp > DateTime.MinValue); + } +} +``` + +### 4.5 WriteBatchAndWait Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs` + +```csharp +using ZB.MOM.WW.LmxProxy.Client.Domain; + +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class WriteBatchAndWaitTests : IntegrationTestBase +{ + [Fact] + public async Task WriteBatchAndWait_TypeAwareComparison() + { + // This test requires a writable tag and a flag tag. + // Adjust tag names based on available tags in JoeAppEngine. + // Example: write values and poll a flag. + + var values = new Dictionary + { + ["JoeAppEngine.BTCS"] = new TypedValue { StringValue = "BatchTest" } + }; + + // Poll the same tag we wrote to (simple self-check) + var response = await Client!.WriteBatchAndWaitAsync( + values, + flagTag: "JoeAppEngine.BTCS", + flagValue: new TypedValue { StringValue = "BatchTest" }, + timeoutMs: 5000, + pollIntervalMs: 200); + + Assert.True(response.Success); + Assert.True(response.FlagReached); + Assert.True(response.ElapsedMs < 5000); + } +} +``` + +### 4.6 CheckApiKey Tests + +**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs` + +```csharp +namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; + +public class CheckApiKeyTests : IntegrationTestBase +{ + [Fact] + public async Task CheckApiKey_ValidReadWrite_ReturnsValid() + { + var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey); + Assert.True(info.IsValid); + } + + [Fact] + public async Task CheckApiKey_ValidReadOnly_ReturnsValid() + { + var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey); + Assert.True(info.IsValid); + } + + [Fact] + public async Task CheckApiKey_Invalid_ReturnsInvalid() + { + var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345"); + Assert.False(info.IsValid); + } +} +``` + +## Step 5: Run Integration Tests + +### 5.1 Build the test project (from Mac) + +```bash +cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy +dotnet build tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests +``` + +### 5.2 Run integration tests against v2 on alternate port + +```bash +dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal +``` + +All tests should pass against `10.100.0.48:50052`. + +### 5.3 Debug failures + +If tests fail, check: +1. v2 service is running: `ssh windev "sc query ZB.MOM.WW.LmxProxy.Host.V2"` +2. v2 service logs: `ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i error"` +3. Network connectivity: `curl -s http://10.100.0.48:8081/api/health` +4. API keys match: `ssh windev "type C:\publish-v2\apikeys.json"` + +### 5.4 Verify metrics after test run + +```bash +curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool +``` + +Should show non-zero operation counts for Read, ReadBatch, Write, etc. + +## Step 6: Cutover + +**Only proceed if ALL integration tests pass.** + +### 6.1 Stop v1 service + +```bash +ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host" +``` + +Verify stopped: + +```bash +ssh windev "sc query ZB.MOM.WW.LmxProxy.Host" +``` + +Expected: `STATE: 1 STOPPED`. + +### 6.2 Stop v2 service + +```bash +ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host.V2" +``` + +### 6.3 Reconfigure v2 to production ports + +Update `C:\publish-v2\appsettings.json`: +- Change `GrpcPort` from `50052` to `50051` +- Change `WebServer.Port` from `8081` to `8080` +- Change log file prefix from `lmxproxy-v2-` to `lmxproxy-` + +```bash +ssh windev "powershell -Command \"(Get-Content 'C:\publish-v2\appsettings.json') -replace '50052','50051' -replace '8081','8080' -replace 'lmxproxy-v2-','lmxproxy-' | Set-Content 'C:\publish-v2\appsettings.json'\"" +``` + +### 6.4 Uninstall v1 service + +```bash +ssh windev "C:\publish\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host\"" +``` + +### 6.5 Uninstall v2 test service and reinstall as production service + +```bash +ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\"" +``` + +```bash +ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host\" -displayname \"SCADA Bridge LMX Proxy\" -description \"LmxProxy v2 gRPC service\" --autostart" +``` + +### 6.6 Start the production service + +```bash +ssh windev "sc start ZB.MOM.WW.LmxProxy.Host" +``` + +### 6.7 Verify on production ports + +```bash +ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host" +``` + +Expected: `STATE: 4 RUNNING`. + +```bash +curl -s http://10.100.0.48:8080/api/health +``` + +Expected: `OK`. + +```bash +curl -s http://10.100.0.48:8080/api/status | python3 -m json.tool | head -15 +``` + +Expected: Connected, version shows v2. + +### 6.8 Update test configuration and re-run integration tests + +Update `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json`: +- Change `Port` from `50052` to `50051` + +```bash +dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal +``` + +All tests should pass on the production port. + +### 6.9 Configure service recovery + +```bash +ssh windev "sc failure ZB.MOM.WW.LmxProxy.Host reset= 86400 actions= restart/60000/restart/300000/restart/600000" +``` + +This configures: restart after 1 min on first failure, 5 min on second, 10 min on subsequent. Reset counter after 1 day (86400 seconds). + +## Step 7: Documentation Updates + +### 7.1 Update windev.md + +Add a section about the LmxProxy v2 service to `/Users/dohertj2/Desktop/scadalink-design/windev.md`: + +```markdown +## LmxProxy v2 + +| Field | Value | +|---|---| +| Service Name | ZB.MOM.WW.LmxProxy.Host | +| Display Name | SCADA Bridge LMX Proxy | +| gRPC Port | 50051 | +| Status Page | http://10.100.0.48:8080/ | +| Health Endpoint | http://10.100.0.48:8080/api/health | +| Publish Directory | C:\publish-v2\ | +| API Keys | C:\publish-v2\apikeys.json | +| Logs | C:\publish-v2\logs\ | +| Protocol | v2 (TypedValue + QualityCode) | +``` + +### 7.2 Update lmxproxy CLAUDE.md + +If `lmxproxy/CLAUDE.md` references v1 behavior, update: +- Change "currently v1 protocol" references to "v2 protocol" +- Update publish directory references from `C:\publish\` to `C:\publish-v2\` +- Update any value conversion notes (no more string heuristics) + +### 7.3 Clean up v1 publish directory (optional) + +```bash +ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup" +``` + +## Step 8: Veeam Backup + +### 8.1 Take incremental backup + +```bash +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')\"" +``` + +### 8.2 Wait for backup to complete (check status) + +```bash +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\"" +``` + +Expected: `State: Stopped, Result: Success`. + +### 8.3 Get the restore point ID + +```bash +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 Id, CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\"" +``` + +### 8.4 Record in windev.md + +Add a new row to the Restore Points table in `windev.md`: + +```markdown +| `XXXXXXXX` | 2026-XX-XX XX:XX | Increment | **Post-v2 deployment** — LmxProxy v2 live on port 50051 | +``` + +Replace placeholders with actual restore point ID and timestamp. + +## Completion Criteria + +- [ ] v2 Host binary published to `C:\publish-v2\` on windev +- [ ] v2 service installed and running on alternate ports (50052/8081) — verified via status page +- [ ] Integration test project created at `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/` +- [ ] All integration tests pass against v2 on alternate ports: + - [ ] Connect/disconnect lifecycle + - [ ] Read string tag `JoeAppEngine.Area` — value "JoeDev", Good quality + - [ ] Read writable tag `JoeAppEngine.BTCS` + - [ ] Write string then read-back verification + - [ ] ReadBatch multiple tags + - [ ] Subscribe to `JoeAppEngine.Scheduler.ScanTime` — verify updates received with TypedValue + QualityCode + - [ ] WriteBatchAndWait with type-aware flag comparison + - [ ] CheckApiKey — valid ReadWrite, valid ReadOnly, invalid + - [ ] Write with ReadOnly key — PermissionDenied + - [ ] Connect with invalid API key — Unauthenticated +- [ ] v1 service stopped and uninstalled +- [ ] v2 service reconfigured to production ports (50051/8080) and reinstalled +- [ ] All integration tests pass on production ports +- [ ] Service recovery configured (restart on failure) +- [ ] `windev.md` updated with v2 service details +- [ ] `lmxproxy/CLAUDE.md` updated for v2 +- [ ] Veeam backup taken and restore point ID recorded in `windev.md` +- [ ] v1 publish directory backed up or removed diff --git a/windev.md b/windev.md index b004af0..aaf444f 100644 --- a/windev.md +++ b/windev.md @@ -129,7 +129,7 @@ ssh windev "winget install --id --silent --disable-interactivity" | 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 Framework** | 4.8.1 (Developer Pack) | GAC / Reference Assemblies (v4.8.1 ref assemblies present) | | **.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\` |