From 0b4b2e4cfdeb2fb551e39651ba2e53fdb9361df6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 19:25:21 -0400 Subject: [PATCH] refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend) The HistorianGateway driver is now the sole historian read/write+alarm backend, so the Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver, .Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver factory): the Host probe registration, the AdminUI driver-config surface (driver page, tag-config editor/model/validator entry, address picker/builder, driver-type catalog + dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes the stale XML docs that named Wonderware as the production backend. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- ZB.MOM.WW.OtOpcUa.slnx | 5 - .../IAlarmHistorianSink.cs | 11 +- .../GatewayHistorianDataSource.cs | 3 +- .../Mapping/GatewayQualityMapper.cs | 9 +- .../WonderwareHistorianClientOptions.cs | 71 -- ...storian.Wonderware.Client.Contracts.csproj | 9 - .../Internal/FrameChannel.cs | 230 ----- .../Internal/QualityMapper.cs | 42 - .../Ipc/Contracts.cs | 232 ----- .../Ipc/FrameReader.cs | 78 -- .../Ipc/FrameWriter.cs | 64 -- .../Ipc/Framing.cs | 48 - .../Ipc/Hello.cs | 44 - .../WonderwareHistorianClient.cs | 607 ------------ .../WonderwareHistorianDriverProbe.cs | 93 -- ....Driver.Historian.Wonderware.Client.csproj | 30 - .../AahClientManagedAlarmEventWriter.cs | 117 --- .../Backend/AlarmHistorianWriteOutcome.cs | 19 - .../Backend/HistorianClusterEndpointPicker.cs | 148 --- .../Backend/HistorianClusterNodeState.cs | 29 - .../Backend/HistorianConfiguration.cs | 49 - .../Backend/HistorianDataSource.cs | 863 ----------------- .../Backend/HistorianEventDto.cs | 29 - .../Backend/HistorianHealthSnapshot.cs | 41 - .../Backend/HistorianQualityMapper.cs | 48 - .../Backend/HistorianSample.cs | 35 - .../Backend/IAlarmHistorianWriteBackend.cs | 32 - .../Backend/IHistorianConnectionFactory.cs | 105 --- .../Backend/IHistorianDataSource.cs | 65 -- .../Backend/SdkAlarmHistorianWriteBackend.cs | 398 -------- .../Ipc/Contracts.cs | 270 ------ .../Ipc/FrameReader.cs | 78 -- .../Ipc/FrameWriter.cs | 66 -- .../Ipc/Framing.cs | 48 - .../Ipc/Hello.cs | 41 - .../Ipc/HistorianFrameHandler.cs | 334 ------- .../Ipc/IFrameHandler.cs | 20 - .../Ipc/TcpFrameServer.cs | 196 ---- .../Program.cs | 178 ---- ...OtOpcUa.Driver.Historian.Wonderware.csproj | 65 -- .../Clusters/Drivers/DriverEditRouter.razor | 1 - .../Clusters/Drivers/DriverTypePicker.razor | 1 - .../HistorianWonderwareDriverPage.razor | 367 -------- .../Drivers/DriverIdentitySection.razor | 1 - .../HistorianWonderwareAddressBuilder.cs | 15 - ...HistorianWonderwareAddressPickerBody.razor | 52 - .../HistorianWonderwareTagConfigEditor.razor | 32 - .../HistorianWonderwareTagConfigModel.cs | 49 - .../Uns/TagEditors/TagConfigEditorMap.cs | 1 - .../Uns/TagEditors/TagConfigValidator.cs | 1 - .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 1 - .../Drivers/DriverFactoryBootstrap.cs | 2 - src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 5 +- .../ZB.MOM.WW.OtOpcUa.Host.csproj | 5 +- .../Drivers/DriverInstanceActor.cs | 12 +- .../Historian/AlarmHistorianOptions.cs | 23 +- .../ServiceCollectionExtensions.cs | 8 +- .../ContractsWireParityTests.cs | 266 ------ .../FakeSidecarServer.cs | 215 ----- .../TcpConnectFactoryTests.cs | 148 --- .../WonderwareHistorianClientOptionsTests.cs | 36 - .../WonderwareHistorianClientTests.cs | 890 ------------------ ...r.Historian.Wonderware.Client.Tests.csproj | 27 - .../AahClientManagedAlarmEventWriterTests.cs | 270 ------ .../HistorianClusterEndpointPickerTests.cs | 101 -- ...HistorianDataSourceConnectFailoverTests.cs | 160 ---- .../HistorianDataSourceHealthSnapshotTests.cs | 114 --- .../HistorianDataSourceRequestTimeoutTests.cs | 114 --- ...DataSourceStartQueryClassificationTests.cs | 104 -- ...storianDataSourceValueAndAggregateTests.cs | 134 --- .../Backend/HistorianQualityMapperTests.cs | 69 -- .../SdkAlarmHistorianWriteBackendTests.cs | 323 ------- .../Ipc/HistorianEventClassifierTests.cs | 226 ----- .../Ipc/TcpRoundTripTests.cs | 297 ------ .../ProgramAlarmWriterTests.cs | 92 -- .../ProgramSmokeTests.cs | 22 - ...a.Driver.Historian.Wonderware.Tests.csproj | 37 - .../DriverPageJsonConverterTests.cs | 2 +- ...derwareDriverPageFormSerializationTests.cs | 129 --- .../HistorianWonderwareAddressBuilderTests.cs | 29 - .../HistorianWonderwareTagConfigModelTests.cs | 100 -- .../Uns/TagConfigValidatorTests.cs | 5 - .../DriverProbeRegistrationTests.cs | 1 - .../AlarmHistorianRegistrationTests.cs | 45 +- 84 files changed, 37 insertions(+), 9345 deletions(-) delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AahClientManagedAlarmEventWriter.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AlarmHistorianWriteOutcome.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterEndpointPicker.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterNodeState.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianConfiguration.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianDataSource.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianEventDto.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianHealthSnapshot.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianQualityMapper.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianSample.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IAlarmHistorianWriteBackend.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianConnectionFactory.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianDataSource.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/SdkAlarmHistorianWriteBackend.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameReader.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameWriter.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Framing.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Hello.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressPickerBody.razor delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/FakeSidecarServer.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/AahClientManagedAlarmEventWriterTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianClusterEndpointPickerTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceConnectFailoverTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceHealthSnapshotTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceRequestTimeoutTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceStartQueryClassificationTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceValueAndAggregateTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianQualityMapperTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/SdkAlarmHistorianWriteBackendTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/HistorianEventClassifierTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs delete mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/HistorianWonderwareAddressBuilderTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 1c4b6908..14ad26d1 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -24,10 +24,7 @@ - - - @@ -86,9 +83,7 @@ - - diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs index 8fc95c86..cf74b82a 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs @@ -1,10 +1,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; /// -/// The historian sink contract — where qualifying alarm events land. Phase 7 plan -/// decision #17: ingestion routes through the Wonderware historian sidecar -/// (WonderwareHistorianClient), which owns the aahClientManaged DLLs -/// and 32-bit constraints. Tests use an in-memory fake; production uses +/// The historian sink contract — where qualifying alarm events land. Ingestion routes +/// through the HistorianGateway alarm writer (the gateway's SendEvent gRPC path) +/// behind the durable store-and-forward queue. Tests use an in-memory fake; production uses /// . /// /// @@ -80,7 +79,7 @@ public enum HistorianDrainState BackingOff, } -/// Returned by the Wonderware historian sidecar per event — drain worker uses this to decide retry cadence. +/// Returned by the historian alarm writer per event — drain worker uses this to decide retry cadence. public enum HistorianWriteOutcome { /// Successfully persisted to the historian. Remove from queue. @@ -91,7 +90,7 @@ public enum HistorianWriteOutcome PermanentFail, } -/// What the drain worker delegates writes to — production is WonderwareHistorianClient (the Wonderware historian sidecar). +/// What the drain worker delegates writes to — production is the HistorianGateway alarm writer (the gateway's SendEvent gRPC path). public interface IAlarmHistorianWriter { /// Push a batch of events to the historian. Returns one outcome per event, same order. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index 5a85ef63..0eccf516 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -261,7 +261,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// requested timestamp, in request order. Returned samples are indexed by timestamp ticks; /// any requested timestamp the gateway did not return is filled with a Bad-quality /// (0x80000000) snapshot stamped at the requested time rather than positionally - /// misaligning values. Ported from WonderwareHistorianClient.AlignAtTimeSnapshots. + /// misaligning values. The alignment logic was ported from the now-retired Wonderware + /// client's at-time snapshot reconciliation. /// private static IReadOnlyList AlignAtTimeSnapshots( IReadOnlyList timestampsUtc, IReadOnlyList samples) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs index a4829da2..fcd9d8a6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs @@ -5,11 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; /// uint. /// /// -/// Byte-identical port of -/// ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map (itself a -/// port of the sidecar's HistorianQualityMapper.Map). The table is duplicated rather than -/// shared because the projects do not share an assembly; a change to the quality table must be -/// applied in every copy and is kept in parity by the per-byte tests. +/// Byte-identical port of the historical Wonderware client's QualityMapper.Map (itself a +/// port of the original historian sidecar's HistorianQualityMapper.Map). Those projects have +/// since been retired; this is now the canonical quality table. Parity with the OPC DA quality +/// semantics is pinned by the per-byte tests. /// internal static class GatewayQualityMapper { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs deleted file mode 100644 index afe856ee..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; - -/// -/// Connection options for WonderwareHistorianClient. -/// -/// -/// -/// Retry / backoff ownership (finding 006): this module performs exactly one -/// in-place transport reconnect inside FrameChannel.InvokeAsync with no delay, -/// and does NOT implement exponential reconnect backoff. Broader retry/backoff is the -/// caller's responsibility — the alarm drain worker -/// (Core.AlarmHistorian.SqliteStoreAndForwardSink) and the read-side -/// history router are expected to layer their own backoff on top. -/// -/// -/// Sidecar TCP host (DNS name or IP) the client dials. -/// Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT). Valid range: 1–65535. -/// Per-process shared secret the sidecar will verify in the Hello frame. -/// Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id. -/// Cap on the TCP connect + Hello round trip on each (re)connect. -/// Cap on a single read/write call once connected. -public sealed record WonderwareHistorianClientOptions( - string Host, - [Range(1, 65535)] int Port, - string SharedSecret, - string PeerName = "OtOpcUa", - TimeSpan? ConnectTimeout = null, - TimeSpan? CallTimeout = null) -{ - /// Gets the effective connect timeout, using the default if not explicitly set. - public TimeSpan EffectiveConnectTimeout => ConnectTimeout ?? TimeSpan.FromSeconds(10); - - /// Gets the effective call timeout, using the default if not explicitly set. - public TimeSpan EffectiveCallTimeout => CallTimeout ?? TimeSpan.FromSeconds(30); - - /// - /// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a - /// 60s server-side maximum; this default is what the form pre-fills for new instances. - /// - [Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 15s.", GroupName = "Diagnostics")] - [Range(1, 60)] - public int ProbeTimeoutSeconds { get; init; } = 15; - - /// When true, the client wraps the TCP stream in TLS before the Hello handshake. - public bool UseTls { get; init; } - - /// - /// Optional SHA-1 thumbprint (40 hex characters, no spaces, case-insensitive) the client - /// pins the sidecar's TLS server cert against. When null/empty and - /// is true, the client validates the cert chain normally - /// (CA-issued cert). - /// - /// - /// The consumer matches against X509Certificate.GetCertHashString() (SHA-1, 40 - /// hex chars). Supplying a SHA-256 thumbprint (64 hex chars, the format shown by modern - /// tooling such as certutil or Windows Certificate Manager) will never match and - /// will cause the TLS handshake to fail silently. Only 40-character SHA-1 hex strings - /// are accepted. - /// - public string? ServerCertThumbprint { get; init; } - - /// - /// - /// Redacts so the value cannot appear in log output when the - /// options object is passed to a structured-logging statement. - /// - public override string ToString() => - $"WonderwareHistorianClientOptions {{ Host={Host}, Port={Port}, PeerName={PeerName}, UseTls={UseTls}, ServerCertThumbprint={ServerCertThumbprint ?? ""} }}"; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj deleted file mode 100644 index 3896f7bd..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - net10.0 - enable - enable - true - - - diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs deleted file mode 100644 index 7b4dc1b4..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Authentication; -using MessagePack; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; - -/// -/// Owns one TCP connection to the Wonderware historian sidecar. Handles the Hello -/// handshake, serializes outgoing requests + waits for the matching reply frame, and -/// reconnects on transport failure with exponential backoff. -/// -/// -/// Single in-flight call at a time — the sidecar's TCP protocol is request/response -/// over a single bidirectional stream, so multiple concurrent -/// calls would interleave replies. A serializes them. PR 6.x -/// can layer batching on top. -/// -internal sealed class FrameChannel : IAsyncDisposable -{ - private readonly WonderwareHistorianClientOptions _options; - private readonly Func> _connect; - private readonly ILogger _logger; - private readonly SemaphoreSlim _callGate = new(1, 1); - - private Stream? _stream; - private FrameReader? _reader; - private FrameWriter? _writer; - private bool _disposed; - - /// - /// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream - /// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake + - /// shared secret still authenticate the caller on top of this. - /// - public static readonly Func> DefaultTcpConnectFactory = - async (opts, ct) => - { - if (string.IsNullOrWhiteSpace(opts.Host)) - throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport."); - - var tcp = new TcpClient(); - try - { - using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - connectCts.CancelAfter(opts.EffectiveConnectTimeout); - await tcp.ConnectAsync(opts.Host, opts.Port, connectCts.Token).ConfigureAwait(false); - } - catch - { - tcp.Dispose(); - throw; - } - tcp.NoDelay = true; - - // The returned NetworkStream owns the socket (TcpClient.GetStream() uses ownsSocket: true), - // so FrameChannel.ResetTransport() disposing this stream closes the underlying socket. - Stream stream = tcp.GetStream(); - if (!opts.UseTls) return stream; - - var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) => - { - if (!string.IsNullOrEmpty(opts.ServerCertThumbprint)) - return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase); - return errors == SslPolicyErrors.None; - }); - try - { - await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = opts.Host }, ct).ConfigureAwait(false); - } - catch - { - await ssl.DisposeAsync().ConfigureAwait(false); - throw; - } - return ssl; - }; - - /// Initializes a new instance of the class. - /// Configuration options for the historian client. - /// Function to establish a connection stream. - /// Logger instance for diagnostics. - public FrameChannel( - WonderwareHistorianClientOptions options, - Func> connect, - ILogger logger) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _connect = connect ?? throw new ArgumentNullException(nameof(connect)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// Gets a value indicating whether the channel is currently connected. - public bool IsConnected => _stream is not null; - - /// - /// Connects + performs the Hello handshake. Returns when the sidecar has accepted the - /// hello. Throws on rejection (bad secret, version mismatch, or transport failure). - /// - /// Cancellation token to stop the operation. - /// A task representing the asynchronous connection operation. - public async Task ConnectAsync(CancellationToken ct) - { - ObjectDisposedException.ThrowIf(_disposed, this); - await _callGate.WaitAsync(ct).ConfigureAwait(false); - try - { - await ConnectInternalAsync(ct).ConfigureAwait(false); - } - finally { _callGate.Release(); } - } - - /// - /// Sends one request, waits for the matching reply. On transport failure, reconnects - /// once and retries — broader retry policy lives in the calling layer. - /// - /// The type of the request payload. - /// The type of the reply payload. - /// The message kind of the request. - /// The expected message kind of the reply. - /// The request payload to send. - /// Cancellation token to stop the operation. - /// A task that returns the reply payload. - public async Task InvokeAsync( - MessageKind requestKind, - MessageKind expectedReplyKind, - TRequest request, - CancellationToken cancellationToken) - where TReply : class - { - ObjectDisposedException.ThrowIf(_disposed, this); - - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(_options.EffectiveCallTimeout); - - await _callGate.WaitAsync(timeout.Token).ConfigureAwait(false); - try - { - // Lazy connect on first call. - if (_stream is null) await ConnectInternalAsync(timeout.Token).ConfigureAwait(false); - - try - { - return await ExchangeAsync(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false); - } - catch (Exception ex) when (ex is IOException or EndOfStreamException or ObjectDisposedException) - { - _logger.LogWarning(ex, "Sidecar TCP transport failure on {Kind}; reconnecting", requestKind); - ResetTransport(); - await ConnectInternalAsync(timeout.Token).ConfigureAwait(false); - // One retry. If the second attempt also fails, propagate. - return await ExchangeAsync(requestKind, expectedReplyKind, request, timeout.Token).ConfigureAwait(false); - } - } - finally { _callGate.Release(); } - } - - private async Task ExchangeAsync( - MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, CancellationToken ct) - { - await _writer!.WriteAsync(requestKind, request, ct).ConfigureAwait(false); - var frame = await _reader!.ReadFrameAsync(ct).ConfigureAwait(false) - ?? throw new EndOfStreamException("Sidecar closed connection before reply."); - if (frame.Kind != expectedReplyKind) - { - throw new InvalidDataException( - $"Sidecar replied with kind {frame.Kind}; expected {expectedReplyKind}."); - } - return MessagePackSerializer.Deserialize(frame.Body); - } - - private async Task ConnectInternalAsync(CancellationToken ct) - { - ResetTransport(); - - _stream = await _connect(ct).ConfigureAwait(false); - _reader = new FrameReader(_stream, leaveOpen: true); - _writer = new FrameWriter(_stream, leaveOpen: true); - - var hello = new Hello - { - ProtocolMajor = Hello.CurrentMajor, - ProtocolMinor = Hello.CurrentMinor, - PeerName = _options.PeerName, - SharedSecret = _options.SharedSecret, - }; - await _writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false); - - var ackFrame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false) - ?? throw new EndOfStreamException("Sidecar closed connection before HelloAck."); - if (ackFrame.Kind != MessageKind.HelloAck) - { - ResetTransport(); - throw new InvalidDataException($"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck."); - } - - var ack = MessagePackSerializer.Deserialize(ackFrame.Body); - if (!ack.Accepted) - { - ResetTransport(); - throw new UnauthorizedAccessException( - $"Sidecar rejected Hello: {ack.RejectReason ?? ""}."); - } - - _logger.LogInformation("Sidecar TCP connected — host={Host}", ack.HostName); - } - - private void ResetTransport() - { - _writer?.Dispose(); - _reader?.Dispose(); - _stream?.Dispose(); - _writer = null; - _reader = null; - _stream = null; - } - - /// Releases all resources associated with this channel. - /// A task representing the asynchronous disposal operation. - public ValueTask DisposeAsync() - { - if (_disposed) return ValueTask.CompletedTask; - _disposed = true; - ResetTransport(); - _callGate.Dispose(); - return ValueTask.CompletedTask; - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs deleted file mode 100644 index c244176d..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; - -/// -/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's OpcQuality) -/// to an OPC UA StatusCode uint. Byte-identical port of the sidecar's -/// HistorianQualityMapper.Map — kept in sync via parity tests rather than a -/// shared assembly because the sidecar is .NET 4.8 (x64) and the client is .NET 10 (x64). -/// -internal static class QualityMapper -{ - /// Maps an OPC DA quality byte to an OPC UA StatusCode. - /// The OPC DA quality byte value. - /// An OPC UA StatusCode as a uint. - public static uint Map(byte q) => q switch - { - // Good family (192+) - 192 => 0x00000000u, // Good - 216 => 0x00D80000u, // Good_LocalOverride - - // Uncertain family (64-191) - 64 => 0x40000000u, // Uncertain - 68 => 0x40900000u, // Uncertain_LastUsableValue - 80 => 0x40930000u, // Uncertain_SensorNotAccurate - 84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded - 88 => 0x40950000u, // Uncertain_SubNormal - - // Bad family (0-63) - 0 => 0x80000000u, // Bad - 4 => 0x80890000u, // Bad_ConfigurationError - 8 => 0x808A0000u, // Bad_NotConnected - 12 => 0x808B0000u, // Bad_DeviceFailure - 16 => 0x808C0000u, // Bad_SensorFailure - 20 => 0x80050000u, // Bad_CommunicationError - 24 => 0x808D0000u, // Bad_OutOfService - 32 => 0x80320000u, // Bad_WaitingForInitialData - - // Unknown — fall back to category bucket so callers still get something usable. - _ when q >= 192 => 0x00000000u, - _ when q >= 64 => 0x40000000u, - _ => 0x80000000u, - }; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs deleted file mode 100644 index 7506173c..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs +++ /dev/null @@ -1,232 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -// ============================================================================ -// Wire DTOs for the sidecar pipe protocol — byte-identical mirror of the -// sidecar's Contracts.cs. The sidecar is .NET 4.8 x64; this client is .NET 10 -// x64. Both ends carry their own copy of these MessagePack DTOs and stay in -// sync via the round-trip tests in PR 3.4 + the byte-equality parity test. -// -// MessagePack [Key] indices MUST match the sidecar's exactly. Adding a field -// is an additive change as long as it lands at a fresh index on both sides; -// reordering or removing keys is a wire break. -// -// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's -// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc). -// ============================================================================ - -/// Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode. -[MessagePackObject] -public sealed class HistorianSampleDto -{ - /// MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type. - [Key(0)] public byte[]? ValueBytes { get; set; } - - /// Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality). - [Key(1)] public byte Quality { get; set; } - - /// Gets the UTC timestamp in ticks. - [Key(2)] public long TimestampUtcTicks { get; set; } -} - -/// Aggregate bucket; Value is null when the aggregate is unavailable for the bucket. -[MessagePackObject] -public sealed class HistorianAggregateSampleDto -{ - /// Gets or sets the aggregate value. - [Key(0)] public double? Value { get; set; } - /// Gets or sets the UTC timestamp in ticks. - [Key(1)] public long TimestampUtcTicks { get; set; } -} - -/// Historian event row. -[MessagePackObject] -public sealed class HistorianEventDto -{ - /// Gets or sets the event identifier. - [Key(0)] public string EventId { get; set; } = string.Empty; - /// Gets or sets the event source name. - [Key(1)] public string? Source { get; set; } - /// Gets or sets the event time in UTC ticks. - [Key(2)] public long EventTimeUtcTicks { get; set; } - /// Gets or sets the received time in UTC ticks. - [Key(3)] public long ReceivedTimeUtcTicks { get; set; } - /// Gets or sets the event display text. - [Key(4)] public string? DisplayText { get; set; } - /// Gets or sets the event severity. - [Key(5)] public ushort Severity { get; set; } -} - -/// Alarm event to persist back into the historian event store. -[MessagePackObject] -public sealed class AlarmHistorianEventDto -{ - /// Gets or sets the event identifier. - [Key(0)] public string EventId { get; set; } = string.Empty; - /// Gets or sets the source name. - [Key(1)] public string SourceName { get; set; } = string.Empty; - /// Gets or sets the condition identifier. - [Key(2)] public string? ConditionId { get; set; } - /// Gets or sets the alarm type. - [Key(3)] public string AlarmType { get; set; } = string.Empty; - /// Gets or sets the alarm message. - [Key(4)] public string? Message { get; set; } - /// Gets or sets the alarm severity. - [Key(5)] public ushort Severity { get; set; } - /// Gets or sets the event time in UTC ticks. - [Key(6)] public long EventTimeUtcTicks { get; set; } - /// Gets or sets the acknowledgment comment. - [Key(7)] public string? AckComment { get; set; } -} - -// ===== Read Raw ===== - -[MessagePackObject] -public sealed class ReadRawRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - /// Gets or sets the maximum number of values to read. - [Key(3)] public int MaxValues { get; set; } - /// Gets or sets the correlation identifier. - [Key(4)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadRawReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - /// Gets or sets a value indicating whether the operation succeeded. - [Key(1)] public bool Success { get; set; } - /// Gets or sets the error message if the operation failed. - [Key(2)] public string? Error { get; set; } - /// Gets or sets the historian samples. - [Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty(); -} - -// ===== Read Processed ===== - -[MessagePackObject] -public sealed class ReadProcessedRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - /// Gets or sets the interval in milliseconds. - [Key(3)] public double IntervalMs { get; set; } - - /// - /// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount". - /// The .NET 10 client maps OPC UA aggregate enum → column. - /// - [Key(4)] public string AggregateColumn { get; set; } = string.Empty; - /// Gets or sets the correlation identifier. - [Key(5)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadProcessedReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - /// Gets or sets a value indicating whether the operation succeeded. - [Key(1)] public bool Success { get; set; } - /// Gets or sets the error message if the operation failed. - [Key(2)] public string? Error { get; set; } - /// Gets or sets the aggregate sample buckets. - [Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty(); -} - -// ===== Read At-Time ===== - -[MessagePackObject] -public sealed class ReadAtTimeRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - /// Gets or sets the timestamps in UTC ticks. - [Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty(); - /// Gets or sets the correlation identifier. - [Key(2)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadAtTimeReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - /// Gets or sets a value indicating whether the operation succeeded. - [Key(1)] public bool Success { get; set; } - /// Gets or sets the error message if the operation failed. - [Key(2)] public string? Error { get; set; } - /// Gets or sets the historian samples. - [Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty(); -} - -// ===== Read Events ===== - -[MessagePackObject] -public sealed class ReadEventsRequest -{ - /// Gets or sets the source name. - [Key(0)] public string? SourceName { get; set; } - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - /// Gets or sets the maximum number of events to read. - [Key(3)] public int MaxEvents { get; set; } - /// Gets or sets the correlation identifier. - [Key(4)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadEventsReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - /// Gets or sets a value indicating whether the operation succeeded. - [Key(1)] public bool Success { get; set; } - /// Gets or sets the error message if the operation failed. - [Key(2)] public string? Error { get; set; } - /// Gets or sets the historian events. - [Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty(); -} - -// ===== Write Alarm Events ===== - -[MessagePackObject] -public sealed class WriteAlarmEventsRequest -{ - /// Gets or sets the alarm historian events to write. - [Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty(); - /// Gets or sets the correlation identifier. - [Key(1)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class WriteAlarmEventsReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - /// Gets or sets a value indicating whether the operation succeeded. - [Key(1)] public bool Success { get; set; } - /// Gets or sets the error message if the operation failed. - [Key(2)] public string? Error { get; set; } - - /// Per-event success flag, parallel to . - [Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty(); - - /// Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent. - /// Empty ⇒ an older sidecar that only sent ; the client falls back to it. - [Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty(); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs deleted file mode 100644 index e844baf9..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameReader.cs +++ /dev/null @@ -1,78 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -/// -/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call -/// from multiple threads against the same instance. Mirror of -/// the sidecar's FrameReader; kept byte-identical so the wire protocol stays stable. -/// -public sealed class FrameReader : IDisposable -{ - private readonly Stream _stream; - private readonly bool _leaveOpen; - - /// Initializes a new instance of the class. - /// The stream to read frames from. - /// True to leave the stream open after disposal; false to dispose it. - public FrameReader(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - /// Reads a single frame from the stream. - /// A cancellation token. - /// A tuple of the message kind and body bytes, or null at end-of-stream. - public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct) - { - var lengthPrefix = new byte[Framing.LengthPrefixSize]; - if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false)) - return null; // clean EOF on frame boundary - - var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3]; - if (length < 0 || length > Framing.MaxFrameBodyBytes) - throw new InvalidDataException($"Sidecar IPC frame length {length} out of range."); - - // Read the kind byte asynchronously and cancellably — a synchronous ReadByte() - // blocks the thread-pool thread and cannot be interrupted by the call-timeout token - // if the peer stalls mid-frame (finding 005). - var kindBuffer = new byte[Framing.KindByteSize]; - if (!await ReadExactAsync(kindBuffer, ct).ConfigureAwait(false)) - throw new EndOfStreamException("EOF after length prefix, before kind byte."); - - var body = new byte[length]; - if (!await ReadExactAsync(body, ct).ConfigureAwait(false)) - throw new EndOfStreamException("EOF mid-frame."); - - return ((MessageKind)kindBuffer[0], body); - } - - /// Deserializes a frame body from MessagePack binary format. - /// The target type to deserialize the body into. - /// The frame body bytes to deserialize. - /// The deserialized object of the specified type. - public static T Deserialize(byte[] body) => MessagePackSerializer.Deserialize(body); - - private async Task ReadExactAsync(byte[] buffer, CancellationToken ct) - { - var offset = 0; - while (offset < buffer.Length) - { - var read = await _stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct).ConfigureAwait(false); - if (read == 0) - { - if (offset == 0) return false; - throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes."); - } - offset += read; - } - return true; - } - - /// Releases the stream resources if leaveOpen was false. - public void Dispose() - { - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs deleted file mode 100644 index 87b7c5eb..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/FrameWriter.cs +++ /dev/null @@ -1,64 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -/// -/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via -/// . Byte-identical mirror of the sidecar's FrameWriter. -/// -public sealed class FrameWriter : IDisposable -{ - private readonly Stream _stream; - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly bool _leaveOpen; - - /// Initializes a new instance of the FrameWriter class. - /// The underlying stream to write frames to. - /// If true, the stream is not disposed when this writer is disposed. - public FrameWriter(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - /// Writes a length-prefixed, kind-tagged MessagePack frame to the stream. - /// The type of the message to serialize. - /// The frame message kind tag. - /// The message object to serialize and write. - /// The cancellation token. - public async Task WriteAsync(MessageKind kind, T message, CancellationToken ct) - { - var body = MessagePackSerializer.Serialize(message, cancellationToken: ct); - if (body.Length > Framing.MaxFrameBodyBytes) - throw new InvalidOperationException( - $"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap."); - - // 5-byte header: [4-byte big-endian body length][1-byte message kind]. - // The kind byte is folded into the header array so every write inside the gate - // is async+cancellable — a synchronous Stream.WriteByte() blocks the calling - // thread-pool thread and cannot be interrupted by the call-timeout token when - // the peer's receive window is full (same class of bug as finding 005 on reads). - var header = new byte[Framing.LengthPrefixSize + Framing.KindByteSize]; - header[0] = (byte)((body.Length >> 24) & 0xFF); - header[1] = (byte)((body.Length >> 16) & 0xFF); - header[2] = (byte)((body.Length >> 8) & 0xFF); - header[3] = (byte)( body.Length & 0xFF); - header[4] = (byte)kind; - - await _gate.WaitAsync(ct).ConfigureAwait(false); - try - { - await _stream.WriteAsync(header, ct).ConfigureAwait(false); - await _stream.WriteAsync(body, ct).ConfigureAwait(false); - await _stream.FlushAsync(ct).ConfigureAwait(false); - } - finally { _gate.Release(); } - } - - /// Disposes the writer and underlying stream (if not left open). - public void Dispose() - { - _gate.Dispose(); - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs deleted file mode 100644 index 6525c0ed..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Framing.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -/// -/// Length-prefixed framing constants for the Wonderware historian sidecar pipe protocol. -/// Each frame on the wire is: -/// [4-byte big-endian length][1-byte message kind][MessagePack body]. -/// Length is the body size only; the kind byte is not part of the prefixed length. -/// -/// -/// Byte-identical mirror of the sidecar's Driver.Historian.Wonderware.Ipc.Framing. -/// The sidecar is .NET 4.8 x64; this client is .NET 10 x64 — the differing target -/// frameworks mean they cannot share an assembly, so the wire constants are duplicated -/// here. PR 3.4 ships round-trip tests that pin the byte-level parity. -/// -public static class Framing -{ - public const int LengthPrefixSize = 4; - public const int KindByteSize = 1; - - /// 16 MiB cap protects the receiver from a hostile or buggy peer. - public const int MaxFrameBodyBytes = 16 * 1024 * 1024; -} - -/// -/// Wire identifier for each historian sidecar message. Values are stable — never reorder; -/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must -/// agree on every value here. Byte-identical with the sidecar enum. -/// -public enum MessageKind : byte -{ - Hello = 0x01, - HelloAck = 0x02, - - ReadRawRequest = 0x10, - ReadRawReply = 0x11, - - ReadProcessedRequest = 0x12, - ReadProcessedReply = 0x13, - - ReadAtTimeRequest = 0x14, - ReadAtTimeReply = 0x15, - - ReadEventsRequest = 0x16, - ReadEventsReply = 0x17, - - WriteAlarmEventsRequest = 0x20, - WriteAlarmEventsReply = 0x21, -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs deleted file mode 100644 index 738abb55..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Hello.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -/// -/// First frame of every connection. Advertises the sidecar protocol version and the -/// per-process shared secret the supervisor passed at spawn time. Byte-identical mirror -/// of the sidecar's Hello contract. -/// -[MessagePackObject] -public sealed class Hello -{ - public const int CurrentMajor = 1; - public const int CurrentMinor = 0; - - /// Gets or sets the protocol major version. - [Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor; - /// Gets or sets the protocol minor version. - [Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor; - /// Gets or sets the peer name identifying the client. - [Key(2)] public string PeerName { get; set; } = string.Empty; - - /// Per-process shared secret — verified against the value the supervisor passed at spawn time. - [Key(3)] public string SharedSecret { get; set; } = string.Empty; -} - -/// -/// Acknowledgment response to a frame. Indicates acceptance and the remote host name. -/// -[MessagePackObject] -public sealed class HelloAck -{ - /// Gets or sets the protocol major version. - [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor; - /// Gets or sets the protocol minor version. - [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor; - - /// Gets or sets a value indicating whether the connection was accepted. - [Key(2)] public bool Accepted { get; set; } - /// Gets or sets the rejection reason if the connection was not accepted. - [Key(3)] public string? RejectReason { get; set; } - /// Gets or sets the host name of the remote server. - [Key(4)] public string HostName { get; set; } = string.Empty; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs deleted file mode 100644 index 03343cc0..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs +++ /dev/null @@ -1,607 +0,0 @@ -using MessagePack; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; -using ClientHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc.HistorianEventDto; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; - -/// -/// .NET 10 client for the Wonderware historian sidecar (PR 3.3 protocol). Implements both -/// (read paths consumed by -/// Server.History.IHistoryRouter) and -/// (alarm-event drain consumed by Core.AlarmHistorian.SqliteStoreAndForwardSink). -/// -/// -/// The client owns a single with one in-flight call at a time; -/// concurrent calls serialize on the channel's gate. Reconnect is handled inside the -/// channel — transient transport failures retry once before propagating. -/// -public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHistorianWriter, IAsyncDisposable -{ - private readonly FrameChannel _channel; - private readonly object _healthLock = new(); - private DateTime? _lastSuccessUtc; - private DateTime? _lastFailureUtc; - private string? _lastError; - private long _totalQueries; - private long _totalSuccesses; - private long _totalFailures; - private int _consecutiveFailures; - - /// - /// Creates a client that connects to the Wonderware historian sidecar over TCP. - /// Tests that need an in-process duplex pair use the factory. - /// - /// The client connection options. - /// Optional logger for diagnostic output. - public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger? logger = null) - : this(options, ct => FrameChannel.DefaultTcpConnectFactory(options, ct), logger) - { - } - - /// Test seam — inject an arbitrary connect callback. - /// The client connection options. - /// A callback that establishes the connection stream. - /// Optional logger for diagnostic output. - /// A new WonderwareHistorianClient configured for testing. - public static WonderwareHistorianClient ForTests( - WonderwareHistorianClientOptions options, - Func> connect, - ILogger? logger = null) - => new(options, connect, logger); - - private WonderwareHistorianClient( - WonderwareHistorianClientOptions options, - Func> connect, - ILogger? logger) - { - ArgumentNullException.ThrowIfNull(options); - var log = (ILogger?)logger ?? NullLogger.Instance; - _channel = new FrameChannel(options, connect, log); - } - - // ===== IHistorianDataSource ===== - - /// Asynchronously reads raw historical data for a tag within a time range. - /// The full reference path of the tag to read. - /// The start time in UTC for the read range. - /// The end time in UTC for the read range. - /// The maximum number of values to return. - /// The cancellation token. - /// A task that returns the historical read result. - public async Task ReadRawAsync( - string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, - CancellationToken cancellationToken) - { - var req = new ReadRawRequest - { - TagName = fullReference, - StartUtcTicks = startUtc.Ticks, - EndUtcTicks = endUtc.Ticks, - MaxValues = (int)Math.Min(maxValuesPerNode, int.MaxValue), - CorrelationId = Guid.NewGuid().ToString("N"), - }; - var reply = await InvokeAndClassifyAsync( - MessageKind.ReadRawRequest, MessageKind.ReadRawReply, req, - r => (r.Success, r.Error), "ReadRaw", cancellationToken).ConfigureAwait(false); - return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null); - } - - /// Asynchronously reads processed historical data with aggregation for a tag within a time range. - /// - /// is derived client-side as the time-weighted - /// Average × interval-seconds; Wonderware AnalogSummary exposes no Total column. The wire - /// request is issued with the Average column and each returned bucket value is scaled by - /// interval.TotalSeconds, preserving the bucket's status code and timestamp. All - /// other aggregates pass through unchanged. - /// - /// The full reference path of the tag to read. - /// The start time in UTC for the read range. - /// The end time in UTC for the read range. - /// The time interval for aggregation. - /// The type of aggregation to apply. - /// The cancellation token. - /// A task that returns the historical read result with aggregated data. - public async Task ReadProcessedAsync( - string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, - HistoryAggregateType aggregate, CancellationToken cancellationToken) - { - // Total has no AnalogSummary column — request the time-weighted Average and scale - // client-side below (Total = Average × interval-seconds). - var isDerivedTotal = aggregate == HistoryAggregateType.Total; - var wireAggregate = isDerivedTotal ? HistoryAggregateType.Average : aggregate; - - var req = new ReadProcessedRequest - { - TagName = fullReference, - StartUtcTicks = startUtc.Ticks, - EndUtcTicks = endUtc.Ticks, - IntervalMs = interval.TotalMilliseconds, - AggregateColumn = MapAggregate(wireAggregate), - CorrelationId = Guid.NewGuid().ToString("N"), - }; - var reply = await InvokeAndClassifyAsync( - MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply, req, - r => (r.Success, r.Error), "ReadProcessed", cancellationToken).ConfigureAwait(false); - - var buckets = isDerivedTotal - ? ScaleAverageToTotal(reply.Buckets, interval.TotalSeconds) - : reply.Buckets; - return new HistoryReadResult(ToAggregateSnapshots(buckets), ContinuationPoint: null); - } - - /// - /// Derives buckets from time-weighted Average - /// buckets using the time-integral identity Total = Average × interval-seconds. Null - /// (unavailable) buckets are carried through unscaled so the downstream null→BadNoData - /// mapping still fires; non-null values are multiplied by . - /// - private static HistorianAggregateSampleDto[] ScaleAverageToTotal( - HistorianAggregateSampleDto[] averages, double intervalSeconds) - { - if (averages.Length == 0) return averages; - var totals = new HistorianAggregateSampleDto[averages.Length]; - for (var i = 0; i < averages.Length; i++) - { - var avg = averages[i]; - totals[i] = new HistorianAggregateSampleDto - { - // Null (unavailable) average → null total (→ BadNoData downstream). - Value = avg.Value is { } v ? v * intervalSeconds : null, - TimestampUtcTicks = avg.TimestampUtcTicks, - }; - } - return totals; - } - - /// Asynchronously reads historical data at specific timestamps for a tag. - /// The full reference path of the tag to read. - /// The specific timestamps in UTC to read values for. - /// The cancellation token. - /// A task that returns the historical read result with values at the specified times. - public async Task ReadAtTimeAsync( - string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) - { - var ticks = new long[timestampsUtc.Count]; - for (var i = 0; i < timestampsUtc.Count; i++) ticks[i] = timestampsUtc[i].Ticks; - - var req = new ReadAtTimeRequest - { - TagName = fullReference, - TimestampsUtcTicks = ticks, - CorrelationId = Guid.NewGuid().ToString("N"), - }; - var reply = await InvokeAndClassifyAsync( - MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, req, - r => (r.Success, r.Error), "ReadAtTime", cancellationToken).ConfigureAwait(false); - return new HistoryReadResult(AlignAtTimeSnapshots(timestampsUtc, reply.Samples), ContinuationPoint: null); - } - - /// - /// Reconciles a ReadAtTime sidecar reply against the requested timestamps to - /// honour the contract: the result - /// MUST have exactly one snapshot per requested timestamp, in request order. The sidecar - /// is not required to return a sample for every timestamp (e.g. it may drop - /// boundary-less timestamps) nor to preserve order, so each requested timestamp is - /// matched by ticks; any timestamp the sidecar did not return is filled with a - /// Bad-quality (0x80000000) snapshot rather than positionally misaligning values. - /// - private static IReadOnlyList AlignAtTimeSnapshots( - IReadOnlyList timestampsUtc, HistorianSampleDto[] samples) - { - // Index returned samples by timestamp ticks. Duplicate timestamps keep the first. - var byTicks = new Dictionary(samples.Length); - foreach (var sample in samples) - byTicks.TryAdd(sample.TimestampUtcTicks, sample); - - var result = new DataValueSnapshot[timestampsUtc.Count]; - for (var i = 0; i < timestampsUtc.Count; i++) - { - var requested = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc); - if (byTicks.TryGetValue(requested.Ticks, out var dto)) - { - result[i] = new DataValueSnapshot( - Value: DeserializeSampleValue(dto.ValueBytes), - StatusCode: QualityMapper.Map(dto.Quality), - SourceTimestampUtc: requested, - ServerTimestampUtc: DateTime.UtcNow); - } - else - { - // Gap — sidecar returned no sample for this timestamp. Per the contract this - // is a Bad-quality snapshot stamped at the requested time, not a dropped row. - result[i] = new DataValueSnapshot( - Value: null, - StatusCode: 0x80000000u, // Bad - SourceTimestampUtc: requested, - ServerTimestampUtc: DateTime.UtcNow); - } - } - return result; - } - - /// Asynchronously reads historical events within a time range. - /// The source name filter for events, or null to read all sources. - /// The start time in UTC for the read range. - /// The end time in UTC for the read range. - /// The maximum number of events to return. - /// The cancellation token. - /// A task that returns the historical events result. - public async Task ReadEventsAsync( - string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, - CancellationToken cancellationToken) - { - var req = new ReadEventsRequest - { - SourceName = sourceName, - StartUtcTicks = startUtc.Ticks, - EndUtcTicks = endUtc.Ticks, - MaxEvents = maxEvents, - CorrelationId = Guid.NewGuid().ToString("N"), - }; - var reply = await InvokeAndClassifyAsync( - MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply, req, - r => (r.Success, r.Error), "ReadEvents", cancellationToken).ConfigureAwait(false); - return new HistoricalEventsResult(ToHistoricalEvents(reply.Events), ContinuationPoint: null); - } - - /// - /// Returns a snapshot of operation counters and the single TCP channel's connection - /// state. - /// - /// - /// This client owns one TCP channel to the sidecar — it has no notion of - /// separate process / event connections and no per-node telemetry. The single channel's - /// connected state is reported for both - /// and , and - /// / - /// / - /// are intentionally null/empty. Consumers - /// that need to distinguish two connections should read another driver. (Finding 010.) - /// - /// All six counter fields (TotalQueries, TotalSuccesses, TotalFailures, - /// ConsecutiveFailures, LastSuccessTime, LastFailureTime, LastError) are mutated - /// exclusively under _healthLock, so the snapshot is internally consistent — - /// in particular TotalSuccesses + TotalFailures == TotalQueries at every - /// observed snapshot (a call that has started but not yet completed has not - /// incremented any counter). (Finding 003 / 004.) - /// - /// - public HistorianHealthSnapshot GetHealthSnapshot() - { - lock (_healthLock) - { - return new HistorianHealthSnapshot( - TotalQueries: _totalQueries, - TotalSuccesses: _totalSuccesses, - TotalFailures: _totalFailures, - ConsecutiveFailures: _consecutiveFailures, - LastSuccessTime: _lastSuccessUtc, - LastFailureTime: _lastFailureUtc, - LastError: _lastError, - ProcessConnectionOpen: _channel.IsConnected, - EventConnectionOpen: _channel.IsConnected, - ActiveProcessNode: null, - ActiveEventNode: null, - Nodes: []); - } - } - - // ===== IAlarmHistorianWriter ===== - - /// - /// Writes a batch of alarm events to the Wonderware historian via the sidecar. - /// - /// - /// - /// Per-event status: when the sidecar populates the additive - /// wire field (0=Ack, 1=Retry, - /// 2=Permanent), each slot maps directly to / - /// / . - /// The sidecar emits Permanent for structurally-malformed (poison) events, - /// so the store-and-forward drain worker dead-letters them immediately instead of - /// looping to the retry cap. An older sidecar that sends only the legacy - /// boolean is handled by the - /// fallback path below (true→Ack, false→RetryPlease) for rolling-deploy back-compat. - /// - /// - /// Documented boundary: only structurally-malformed events surface as - /// . A structurally-valid event that - /// the AAH historian SDK rejects for a deeper, semantic reason still maps to - /// (→ retry cap), because the sidecar's - /// writer returns only a transient/persisted boolean for events it actually attempts. - /// Surfacing richer SDK-semantic permanent rejections requires the infra-gated - /// AahClientManagedAlarmEventWriter to report a status code rather than a bool. - /// - /// - /// Transport or deserialization failures, and any whole-call failure - /// (Success=false), return for - /// every event in the batch; the drain worker's backoff controls recovery. - /// - /// - /// The batch of alarm historian events to write. - /// The cancellation token. - /// A task that returns per-event write outcomes. - public async Task> WriteBatchAsync( - IReadOnlyList batch, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(batch); - if (batch.Count == 0) return []; - - var dtos = new AlarmHistorianEventDto[batch.Count]; - for (var i = 0; i < batch.Count; i++) dtos[i] = ToDto(batch[i]); - - var req = new WriteAlarmEventsRequest - { - Events = dtos, - CorrelationId = Guid.NewGuid().ToString("N"), - }; - - try - { - var reply = await InvokeAsync( - MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply, req, - r => (r.Success, r.Error), cancellationToken).ConfigureAwait(false); - - // Whole-call failure → transient retry for every event in the batch. - if (!reply.Success) - { - var fail = new HistorianWriteOutcome[batch.Count]; - Array.Fill(fail, HistorianWriteOutcome.RetryPlease); - return fail; - } - - // Prefer the granular per-event status when the sidecar provides it (new wire - // field); fall back to the legacy PerEventOk bool for older sidecars. The sidecar - // emits status 2 (Permanent) for structurally-malformed poison events so they - // dead-letter immediately rather than retrying to the cap. - if (reply.PerEventStatus is { Length: > 0 } status && status.Length == batch.Count) - { - var statusOutcomes = new HistorianWriteOutcome[batch.Count]; - for (var i = 0; i < batch.Count; i++) - statusOutcomes[i] = status[i] switch - { - 0 => HistorianWriteOutcome.Ack, - 2 => HistorianWriteOutcome.PermanentFail, - _ => HistorianWriteOutcome.RetryPlease, // 1 or unknown - }; - return statusOutcomes; - } - - // Legacy fallback: PerEventOk[i] = true → Ack; false → RetryPlease. An older - // sidecar without PerEventStatus can never signal PermanentFail through this - // path, so a poison event retries to the drain worker's cap. - var outcomes = new HistorianWriteOutcome[batch.Count]; - for (var i = 0; i < batch.Count; i++) - { - var ok = i < reply.PerEventOk.Length && reply.PerEventOk[i]; - outcomes[i] = ok ? HistorianWriteOutcome.Ack : HistorianWriteOutcome.RetryPlease; - } - return outcomes; - } - catch - { - // Transport / deserialization failure — every event is retry-please. The drain - // worker's backoff handles recovery. PermanentFail is only emitted from the - // success path's PerEventStatus mapping, never from a transport failure. - var fail = new HistorianWriteOutcome[batch.Count]; - Array.Fill(fail, HistorianWriteOutcome.RetryPlease); - return fail; - } - } - - // ===== Constants ===== - - /// - /// Per-sample ValueBytes size cap. MessagePack with the default - /// (primitive-only — no typeless - /// or dynamic-type resolution) is not susceptible to type-confusion gadget chains, but - /// we still cap the per-sample byte budget to guard against a buggy or unexpectedly - /// large peer payload. 64 KiB is well above any primitive historian value. - /// (Finding 007 — NuGetAuditSuppress GHSA-37gx-xxp4-5rgx / GHSA-w3x6-4m5h-cxqf.) - /// - private const int MaxValueBytesPerSample = 64 * 1024; - - // ===== Helpers ===== - - /// - /// Sends one request through the channel and records the outcome (transport success or - /// transport failure) under a single _healthLock acquisition that also bumps - /// _totalQueries. Sidecar-level success / failure is NOT classified here — the - /// caller passes that through instead. (Finding - /// 003 / 004: all six counter fields share one synchronization mechanism so a snapshot - /// can never observe a torn state.) - /// - private async Task InvokeAsync( - MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, - Func evaluate, CancellationToken ct) - where TReply : class - { - try - { - var reply = await _channel.InvokeAsync(requestKind, expectedReplyKind, request, ct).ConfigureAwait(false); - // Classify transport+sidecar in one lock so TotalQueries/TotalSuccesses/ - // TotalFailures move together and no intermediate "success-then-undo" state is - // visible to a concurrent GetHealthSnapshot. - var (ok, error) = evaluate(reply); - RecordOutcome(ok, error); - return reply; - } - catch (Exception ex) - { - RecordOutcome(success: false, ex.Message); - throw; - } - } - - /// - /// Convenience wrapper around that throws - /// on a sidecar-reported failure. Used by the - /// read methods. - /// - private async Task InvokeAndClassifyAsync( - MessageKind requestKind, MessageKind expectedReplyKind, TRequest request, - Func evaluate, string op, CancellationToken ct) - where TReply : class - { - var reply = await InvokeAsync(requestKind, expectedReplyKind, request, evaluate, ct).ConfigureAwait(false); - var (ok, error) = evaluate(reply); - if (!ok) - { - throw new InvalidOperationException( - $"Sidecar {op} failed: {error ?? ""}."); - } - return reply; - } - - /// - /// Records the outcome of a single call — increments _totalQueries and exactly - /// one of _totalSuccesses / _totalFailures under a single - /// _healthLock acquisition. (Findings 003 + 004.) - /// - private void RecordOutcome(bool success, string? error) - { - lock (_healthLock) - { - _totalQueries++; - if (success) - { - _totalSuccesses++; - _consecutiveFailures = 0; - _lastSuccessUtc = DateTime.UtcNow; - } - else - { - _totalFailures++; - _consecutiveFailures++; - _lastFailureUtc = DateTime.UtcNow; - _lastError = error; - } - } - } - - /// - /// Deserializes a sample's value bytes using the MessagePack default - /// (primitive types only — no - /// typeless or dynamic-type resolution). A per-sample size cap guards against a - /// hostile or buggy peer sending an unexpectedly large payload before deserialization - /// allocates memory for it. (Finding 007.) - /// - private static object? DeserializeSampleValue(byte[]? valueBytes) - { - if (valueBytes is null) return null; - if (valueBytes.Length > MaxValueBytesPerSample) - throw new InvalidDataException( - $"Sidecar sample ValueBytes length {valueBytes.Length} exceeds the {MaxValueBytesPerSample}-byte cap."); - // Deserializes using the default resolver which only handles primitive types - // (bool, int, long, float, double, string, byte[], DateTime, etc.). The resolver - // does NOT support TypelessContractlessStandardResolver so no type-confusion gadget - // chains are reachable from this call site. - return MessagePackSerializer.Deserialize(valueBytes); - } - - private static IReadOnlyList ToSnapshots(HistorianSampleDto[] dtos) - { - if (dtos.Length == 0) return []; - var snapshots = new DataValueSnapshot[dtos.Length]; - for (var i = 0; i < dtos.Length; i++) - { - var dto = dtos[i]; - snapshots[i] = new DataValueSnapshot( - Value: DeserializeSampleValue(dto.ValueBytes), - StatusCode: QualityMapper.Map(dto.Quality), - SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc), - ServerTimestampUtc: DateTime.UtcNow); - } - return snapshots; - } - - private static IReadOnlyList ToAggregateSnapshots(HistorianAggregateSampleDto[] dtos) - { - if (dtos.Length == 0) return []; - var snapshots = new DataValueSnapshot[dtos.Length]; - for (var i = 0; i < dtos.Length; i++) - { - var dto = dtos[i]; - // Null aggregate value → BadNoData per Core.Abstractions HistoryReadResult convention. - snapshots[i] = new DataValueSnapshot( - Value: dto.Value, - StatusCode: dto.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u /* Good */, - SourceTimestampUtc: new DateTime(dto.TimestampUtcTicks, DateTimeKind.Utc), - ServerTimestampUtc: DateTime.UtcNow); - } - return snapshots; - } - - private static IReadOnlyList ToHistoricalEvents(ClientHistorianEventDto[] dtos) - { - if (dtos.Length == 0) return []; - var events = new HistoricalEvent[dtos.Length]; - for (var i = 0; i < dtos.Length; i++) - { - var dto = dtos[i]; - events[i] = new HistoricalEvent( - EventId: dto.EventId, - SourceName: dto.Source, - EventTimeUtc: new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc), - ReceivedTimeUtc: new DateTime(dto.ReceivedTimeUtcTicks, DateTimeKind.Utc), - Message: dto.DisplayText, - Severity: dto.Severity); - } - return events; - } - - private static AlarmHistorianEventDto ToDto(AlarmHistorianEvent evt) => new() - { - EventId = evt.AlarmId, - SourceName = evt.EquipmentPath, - ConditionId = evt.AlarmName, - AlarmType = evt.AlarmTypeName + ":" + evt.EventKind, - Message = evt.Message, - Severity = MapSeverity(evt.Severity), - EventTimeUtcTicks = evt.TimestampUtc.Ticks, - AckComment = evt.Comment, - }; - - private static ushort MapSeverity(AlarmSeverity severity) => severity switch - { - AlarmSeverity.Low => 250, - AlarmSeverity.Medium => 500, - AlarmSeverity.High => 700, - AlarmSeverity.Critical => 900, - _ => 500, - }; - - /// - /// Maps an OPC UA aggregate to its Wonderware AnalogSummary column name. There is no - /// Total column — is derived client-side in - /// by requesting Average, so it is never passed here. - /// - private static string MapAggregate(HistoryAggregateType aggregate) => aggregate switch - { - HistoryAggregateType.Average => "Average", - HistoryAggregateType.Minimum => "Minimum", - HistoryAggregateType.Maximum => "Maximum", - HistoryAggregateType.Count => "ValueCount", - _ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"), - }; - - /// Asynchronously disposes the client and its underlying TCP channel. - /// A task that completes when the client has been disposed. - public ValueTask DisposeAsync() => _channel.DisposeAsync(); - - /// - /// Synchronous dispose required by on - /// . The underlying channel's async cleanup runs the - /// TCP socket teardown, which can block briefly on OS handle release — strictly speaking - /// it is not non-blocking — but the GetAwaiter()/GetResult() bridge is - /// deadlock-safe because the cleanup never awaits a captured - /// nor takes any lock that the - /// caller could hold. (Finding 010.) - /// - public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs deleted file mode 100644 index edd37bc1..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Diagnostics; -using System.Net.Sockets; -using System.Text.Json; -using System.Text.Json.Serialization; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; - -/// -/// TCP-connect probe for the -shaped driver -/// config. Opens a socket to the configured Host:Port (optionally performing the TLS -/// client handshake when UseTls is set, reusing the same pinned-thumbprint / CA-chain -/// validation as ), then sends a -/// with the configured shared secret and confirms the sidecar's -/// is accepted — a true end-to-end reachability + auth check. -/// Surfaces a green tick + latency on success; a clear red message on timeout / connection -/// refused / TLS failure / rejected Hello. -/// -public sealed class WonderwareHistorianDriverProbe : IDriverProbe -{ - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - Converters = { new JsonStringEnumConverter() }, - }; - - /// - public string DriverType => "Historian.Wonderware"; - - /// - public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) - { - WonderwareHistorianClientOptions? opts; - try { opts = JsonSerializer.Deserialize(configJson, _opts); } - catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } - if (opts is null) return new(false, "Config JSON deserialized to null.", null); - - if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0) - return new(false, "Config has no host/port to probe.", null); - - var sw = Stopwatch.StartNew(); - Stream? stream = null; - try - { - // Reuse the runtime connect factory so the probe exercises the exact TCP + TLS - // (pinned-thumbprint or CA-chain) path the client uses in production. - stream = await FrameChannel.DefaultTcpConnectFactory(opts, ct).ConfigureAwait(false); - - using var reader = new FrameReader(stream, leaveOpen: true); - using var writer = new FrameWriter(stream, leaveOpen: true); - - var hello = new Hello - { - ProtocolMajor = Hello.CurrentMajor, - ProtocolMinor = Hello.CurrentMinor, - PeerName = opts.PeerName, - SharedSecret = opts.SharedSecret, - }; - await writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false); - - var ackFrame = await reader.ReadFrameAsync(ct).ConfigureAwait(false) - ?? throw new EndOfStreamException("Sidecar closed connection before HelloAck."); - if (ackFrame.Kind != MessageKind.HelloAck) - return new(false, $"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.", null); - - var ack = FrameReader.Deserialize(ackFrame.Body); - if (!ack.Accepted) - return new(false, $"Sidecar rejected Hello: {ack.RejectReason ?? ""}.", null); - - sw.Stop(); - return new(true, $"Connected to {opts.Host}:{opts.Port} (tls={opts.UseTls})", sw.Elapsed); - } - catch (SocketException ex) - { - return new(false, $"Connect failed: {ex.SocketErrorCode}", null); - } - catch (OperationCanceledException) - { - return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); - } - catch (Exception ex) - { - return new(false, ex.Message, null); - } - finally - { - if (stream is not null) await stream.DisposeAsync().ConfigureAwait(false); - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj deleted file mode 100644 index 49863e13..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - AnyCPU;x64 - enable - enable - latest - true - true - $(NoWarn);CS1591 - ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client - - - - - - - - - - - - - - - - - - diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AahClientManagedAlarmEventWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AahClientManagedAlarmEventWriter.cs deleted file mode 100644 index 71e46bdb..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AahClientManagedAlarmEventWriter.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// IPC-side implementation that delegates to an - /// (production: aahClientManaged-bound) - /// and maps the trinary down to the - /// bool[] the IPC reply contract carries. Per-event outcomes: - /// - /// true (drop from sender's queue). - /// false (sender retries on next drain tick). - /// false (sender's B.4 widens the IPC bool back into the trinary outcome by inspecting structured diagnostics; this slot intentionally collapses to "not-ok" at the wire). - /// - /// - public sealed class AahClientManagedAlarmEventWriter : IAlarmEventWriter - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly IAlarmHistorianWriteBackend _backend; - - /// - /// Initializes a new instance of the AahClientManagedAlarmEventWriter class. - /// - /// The alarm historian write backend to delegate to. - public AahClientManagedAlarmEventWriter(IAlarmHistorianWriteBackend backend) - { - _backend = backend ?? throw new ArgumentNullException(nameof(backend)); - } - - /// - /// Writes an array of alarm historian events asynchronously. - /// - /// The alarm events to write. - /// Cancellation token. - public async Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken) - { - if (events is null || events.Length == 0) - { - return new bool[0]; - } - - AlarmHistorianWriteOutcome[] outcomes; - try - { - outcomes = await _backend.WriteBatchAsync(events, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - // Backend-level failure (cluster unreachable, transport error). Treat the - // whole batch as RetryPlease so the sender's queue holds the rows for - // the next drain tick — preferable to dropping them on a transient. - Log.Warning(ex, - "Alarm historian backend WriteBatchAsync threw — marking entire {Count}-event batch RetryPlease.", - events.Length); - var fallback = new bool[events.Length]; - return fallback; - } - - if (outcomes.Length != events.Length) - { - // Backend contract violation — defensive degrade so a bug in the backend - // doesn't desync the sender's queue accounting. Treat as RetryPlease. - Log.Warning( - "Alarm historian backend returned {ReturnedCount} outcomes for a batch of {InputCount} events; degrading to RetryPlease for the whole batch.", - outcomes.Length, events.Length); - return new bool[events.Length]; - } - - var perEventOk = new bool[outcomes.Length]; - for (var i = 0; i < outcomes.Length; i++) - { - perEventOk[i] = outcomes[i] == AlarmHistorianWriteOutcome.Ack; - } - return perEventOk; - } - - /// - /// Translate the outcome of a single SDK call (raw HRESULT + diagnostic) into the - /// trinary . Exposed for the production - /// to share the mapping with tests. - /// - /// The HRESULT code from the SDK call. - /// Indicates whether the error is a communication-class error. - /// Indicates whether the input was malformed. - public static AlarmHistorianWriteOutcome MapOutcome(int hresult, bool isCommunicationError, bool isMalformedInput) - { - // Order matters: malformed input is permanent regardless of HRESULT pattern; - // communication-class errors are transient regardless of which specific - // HRESULT bit fired. - if (isMalformedInput) - { - return AlarmHistorianWriteOutcome.PermanentFail; - } - if (hresult == 0) - { - return AlarmHistorianWriteOutcome.Ack; - } - if (isCommunicationError) - { - return AlarmHistorianWriteOutcome.RetryPlease; - } - // Default: unknown HRESULT failure — be conservative and let the sender retry. - // The sender's drain worker has its own dead-letter cap so a permanently-broken - // event won't loop forever. - return AlarmHistorianWriteOutcome.RetryPlease; - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AlarmHistorianWriteOutcome.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AlarmHistorianWriteOutcome.cs deleted file mode 100644 index 6bf863bb..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/AlarmHistorianWriteOutcome.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Per-event outcome from . - /// Sidecar-local twin of Core.AlarmHistorian.HistorianWriteOutcome (the - /// sidecar runs net48 and cannot reference the net10 Core project; the IPC - /// contract narrows this to bool per slot, so the lmxopcua-side consumer - /// widens that back into the trinary outcome at the IPC boundary in PR B.4). - /// - public enum AlarmHistorianWriteOutcome - { - /// Event accepted by the historian. Drop from the store-and-forward queue. - Ack, - /// Transient failure (server busy, disconnected, timeout). Leave queued; retry on next drain tick. - RetryPlease, - /// Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter on the lmxopcua side. - PermanentFail, - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterEndpointPicker.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterEndpointPicker.cs deleted file mode 100644 index a65ff312..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterEndpointPicker.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which - /// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands - /// out an ordered list of eligible candidates for the data source to try in sequence. - /// - internal sealed class HistorianClusterEndpointPicker - { - private readonly Func _clock; - private readonly TimeSpan _cooldown; - private readonly object _lock = new object(); - private readonly List _nodes; - - /// Initializes the picker with default system clock. - /// Historian configuration. - public HistorianClusterEndpointPicker(HistorianConfiguration config) - : this(config, () => DateTime.UtcNow) { } - - /// Initializes the picker with custom clock function. - /// Historian configuration. - /// Clock function for testing. - internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func clock) - { - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - _cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds)); - - var names = (config.ServerNames != null && config.ServerNames.Count > 0) - ? config.ServerNames - : new List { config.ServerName }; - - _nodes = names - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Select(n => n.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(n => new NodeEntry { Name = n }) - .ToList(); - } - - /// Gets the total count of configured nodes. - public int NodeCount - { - get { lock (_lock) return _nodes.Count; } - } - - /// Gets the list of currently healthy nodes. - public IReadOnlyList GetHealthyNodes() - { - lock (_lock) - { - var now = _clock(); - return _nodes.Where(n => IsHealthyAt(n, now)).Select(n => n.Name).ToList(); - } - } - - /// Gets the count of currently healthy nodes. - public int HealthyNodeCount - { - get - { - lock (_lock) - { - var now = _clock(); - return _nodes.Count(n => IsHealthyAt(n, now)); - } - } - } - - /// Marks a node as failed and starts its cooldown. - /// Node name. - /// Optional error message. - public void MarkFailed(string node, string? error) - { - lock (_lock) - { - var entry = FindEntry(node); - if (entry == null) return; - - var now = _clock(); - entry.FailureCount++; - entry.LastError = error; - entry.LastFailureTime = now; - entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null; - } - } - - /// Marks a node as healthy and clears its cooldown. - /// Node name. - public void MarkHealthy(string node) - { - lock (_lock) - { - var entry = FindEntry(node); - if (entry == null) return; - entry.CooldownUntil = null; - } - } - - /// Returns a snapshot of all node states. - public List SnapshotNodeStates() - { - lock (_lock) - { - var now = _clock(); - return _nodes.Select(n => new HistorianClusterNodeState - { - Name = n.Name, - IsHealthy = IsHealthyAt(n, now), - CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil, - FailureCount = n.FailureCount, - LastError = n.LastError, - LastFailureTime = n.LastFailureTime - }).ToList(); - } - } - - private static bool IsHealthyAt(NodeEntry entry, DateTime now) - { - return entry.CooldownUntil == null || entry.CooldownUntil <= now; - } - - private NodeEntry? FindEntry(string node) - { - for (var i = 0; i < _nodes.Count; i++) - if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase)) - return _nodes[i]; - return null; - } - - private sealed class NodeEntry - { - /// Gets or sets the node name. - public string Name { get; set; } = ""; - /// Gets or sets when cooldown expires. - public DateTime? CooldownUntil { get; set; } - /// Gets or sets the failure count. - public int FailureCount { get; set; } - /// Gets or sets the last error message. - public string? LastError { get; set; } - /// Gets or sets the last failure time. - public DateTime? LastFailureTime { get; set; } - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterNodeState.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterNodeState.cs deleted file mode 100644 index 41255374..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianClusterNodeState.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Point-in-time state of a single historian cluster node. One entry per configured node - /// appears inside . - /// - public sealed class HistorianClusterNodeState - { - /// Gets or sets the node name. - public string Name { get; set; } = ""; - - /// Gets or sets a value indicating whether the node is healthy. - public bool IsHealthy { get; set; } - - /// Gets or sets the time until the node exits cooldown mode. - public DateTime? CooldownUntil { get; set; } - - /// Gets or sets the count of recent failures. - public int FailureCount { get; set; } - - /// Gets or sets the last error message. - public string? LastError { get; set; } - - /// Gets or sets the time of the last failure. - public DateTime? LastFailureTime { get; set; } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianConfiguration.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianConfiguration.cs deleted file mode 100644 index 0c7ce2d6..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianConfiguration.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Wonderware Historian SDK configuration. Populated from environment variables at - /// sidecar startup (see Program.cs): the supervisor (lmxopcua-side - /// WonderwareHistorianClient) spawns the sidecar with these env vars; UA - /// translation lives on the client side of the TCP IPC, so this surface is - /// kept OPC-UA-free. The legacy v1 Galaxy.Host / Proxy host this lived in retired - /// in PR 7.2. - /// - public sealed class HistorianConfiguration - { - /// Gets or sets a value indicating whether Historian integration is enabled. - public bool Enabled { get; set; } = false; - - /// Single-node fallback when is empty. - public string ServerName { get; set; } = "localhost"; - - /// - /// Ordered cluster nodes. When non-empty, the data source tries each in order on connect, - /// falling through to the next on failure. A failed node is placed in cooldown for - /// before being re-eligible. - /// - public List ServerNames { get; set; } = new(); - - /// Gets or sets the failure cooldown period in seconds. - public int FailureCooldownSeconds { get; set; } = 60; - /// Gets or sets a value indicating whether to use integrated security. - public bool IntegratedSecurity { get; set; } = true; - /// Gets or sets the user name for authentication. - public string? UserName { get; set; } - /// Gets or sets the password for authentication. - public string? Password { get; set; } - /// Gets or sets the Historian server port. - public int Port { get; set; } = 32568; - /// Gets or sets the command timeout in seconds. - public int CommandTimeoutSeconds { get; set; } = 30; - /// Gets or sets the maximum number of values per read operation. - public int MaxValuesPerRead { get; set; } = 10000; - - /// - /// Outer safety timeout applied to sync-over-async Historian operations. Must be - /// comfortably larger than . - /// - public int RequestTimeoutSeconds { get; set; } = 60; - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianDataSource.cs deleted file mode 100644 index 98b5fbf5..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianDataSource.cs +++ /dev/null @@ -1,863 +0,0 @@ -using System; -using System.Collections.Generic; -using StringCollection = System.Collections.Specialized.StringCollection; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Reads historical data from the Wonderware Historian via the aahClientManaged SDK. - /// OPC-UA-free — emits / - /// which the sidecar serialises onto the TCP wire (PR 3.3 contracts) for the - /// .NET 10 WonderwareHistorianClient to translate into OPC UA DataValue - /// on its side of the IPC. The v1 Galaxy.Host / Proxy architecture this class - /// originally lived in retired in PR 7.2. - /// - public sealed class HistorianDataSource : IHistorianDataSource - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly HistorianConfiguration _config; - private readonly object _connectionLock = new object(); - private readonly object _eventConnectionLock = new object(); - private readonly IHistorianConnectionFactory _factory; - private HistorianAccess? _connection; - private HistorianAccess? _eventConnection; - private bool _disposed; - - private readonly object _healthLock = new object(); - private long _totalSuccesses; - private long _totalFailures; - private int _consecutiveFailures; - private DateTime? _lastSuccessTime; - private DateTime? _lastFailureTime; - private string? _lastError; - private string? _activeProcessNode; - private string? _activeEventNode; - - private readonly HistorianClusterEndpointPicker _picker; - - /// Initializes a new instance of the class with the default connection factory. - /// The historian configuration. - public HistorianDataSource(HistorianConfiguration config) - : this(config, new SdkHistorianConnectionFactory(), null) { } - - /// Initializes a new instance of the class with the specified connection factory and endpoint picker. - /// The historian configuration. - /// The historian connection factory. - /// The optional cluster endpoint picker. - internal HistorianDataSource( - HistorianConfiguration config, - IHistorianConnectionFactory factory, - HistorianClusterEndpointPicker? picker = null) - { - _config = config; - _factory = factory; - _picker = picker ?? new HistorianClusterEndpointPicker(config); - } - - // Error codes that signify the connection or server is the problem rather than the - // query itself. A query-class failure (bad tag name, unsupported aggregate, etc.) must - // not force us to tear down and re-open the (relatively expensive) historian - // connection — that would let a burst of bad-tag queries push an otherwise healthy - // cluster node into cooldown. See Driver.Historian.Wonderware-008. - private static readonly HashSet ConnectionErrorCodes = - new HashSet - { - HistorianAccessError.ErrorValue.FailedToConnect, - HistorianAccessError.ErrorValue.FailedToCreateSession, - HistorianAccessError.ErrorValue.NoReply, - HistorianAccessError.ErrorValue.NotReady, - HistorianAccessError.ErrorValue.NotInitialized, - HistorianAccessError.ErrorValue.Stopping, - HistorianAccessError.ErrorValue.Win32Exception, - HistorianAccessError.ErrorValue.InvalidResponse, - }; - - /// - /// Whether an aahClientManaged error code indicates that the - /// connection (rather than the query payload) is the problem and the - /// shared SDK connection should therefore be reset. Internal for unit testing. - /// - /// The historian access error code. - internal static bool IsConnectionClassError(HistorianAccessError.ErrorValue code) - => ConnectionErrorCodes.Contains(code); - - /// - /// Whether a failed StartQuery in the per-timestamp at-time loop should reset - /// the shared SDK connection (and abort the read) rather than record a per-timestamp - /// Bad sample and continue. Returns true only for connection-class error - /// codes; query-class / no-data codes (and a missing error) return false so - /// a single bad/empty timestamp does not tear down a connection that is still serving - /// the other timestamps. The HistoryQuery SDK type is non-virtual and has no - /// interface, so the at-time loop can't be driven offline — this pure helper is the - /// unit-testable seam for the classification. See Driver.Historian.Wonderware-014. - /// - /// The SDK error returned by the failed StartQuery. - internal static bool ShouldResetConnectionForStartQueryFailure(HistorianAccessError? error) - => IsConnectionClassError(error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure); - - /// - /// Builds the per-read linked into the - /// caller's and pre-wired to fire after - /// if positive. The - /// read paths use the resulting token in their ThrowIfCancellationRequested - /// checks so a hung StartQuery or slow MoveNext cannot block the - /// single TCP-server connection thread indefinitely. See - /// Driver.Historian.Wonderware-010. - /// - /// The historian configuration. - /// The cancellation token. - internal static CancellationTokenSource BuildRequestCts(HistorianConfiguration cfg, CancellationToken ct) - { - var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - if (cfg.RequestTimeoutSeconds > 0) - { - cts.CancelAfter(TimeSpan.FromSeconds(cfg.RequestTimeoutSeconds)); - } - return cts; - } - - private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type) - { - var candidates = _picker.GetHealthyNodes(); - if (candidates.Count == 0) - { - var total = _picker.NodeCount; - throw new InvalidOperationException( - total == 0 - ? "No historian nodes configured" - : $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to"); - } - - Exception? lastException = null; - foreach (var node in candidates) - { - var attemptConfig = CloneConfigWithServerName(node); - try - { - var conn = _factory.CreateAndConnect(attemptConfig, type); - _picker.MarkHealthy(node); - return (conn, node); - } - catch (Exception ex) - { - _picker.MarkFailed(node, ex.Message); - lastException = ex; - Log.Warning(ex, "Historian node {Node} failed during connect attempt; trying next candidate", node); - } - } - - var inner = lastException?.Message ?? "(no detail)"; - throw new InvalidOperationException( - $"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}", - lastException); - } - - private HistorianConfiguration CloneConfigWithServerName(string serverName) - { - return new HistorianConfiguration - { - Enabled = _config.Enabled, - ServerName = serverName, - ServerNames = _config.ServerNames, - FailureCooldownSeconds = _config.FailureCooldownSeconds, - IntegratedSecurity = _config.IntegratedSecurity, - UserName = _config.UserName, - Password = _config.Password, - Port = _config.Port, - CommandTimeoutSeconds = _config.CommandTimeoutSeconds, - MaxValuesPerRead = _config.MaxValuesPerRead - }; - } - - /// Gets a snapshot of the current health status. - public HistorianHealthSnapshot GetHealthSnapshot() - { - var nodeStates = _picker.SnapshotNodeStates(); - var healthyCount = 0; - foreach (var n in nodeStates) - if (n.IsHealthy) healthyCount++; - - // Driver.Historian.Wonderware-005: derive the connection-open booleans from the - // active-node strings, both of which live under _healthLock. _connection itself - // is published under _connectionLock — reading it here under a different lock - // could produce an internally inconsistent snapshot (open with no node, or - // closed with a non-null node) at the publish/clear boundary. Treating the - // active-node strings as the single source of truth makes the snapshot - // self-consistent by construction. - lock (_healthLock) - { - return new HistorianHealthSnapshot - { - TotalQueries = _totalSuccesses + _totalFailures, - TotalSuccesses = _totalSuccesses, - TotalFailures = _totalFailures, - ConsecutiveFailures = _consecutiveFailures, - LastSuccessTime = _lastSuccessTime, - LastFailureTime = _lastFailureTime, - LastError = _lastError, - ProcessConnectionOpen = _activeProcessNode != null, - EventConnectionOpen = _activeEventNode != null, - ActiveProcessNode = _activeProcessNode, - ActiveEventNode = _activeEventNode, - NodeCount = nodeStates.Count, - HealthyNodeCount = healthyCount, - Nodes = nodeStates - }; - } - } - - private void RecordSuccess() - { - lock (_healthLock) - { - _totalSuccesses++; - _lastSuccessTime = DateTime.UtcNow; - _consecutiveFailures = 0; - _lastError = null; - } - } - - private void RecordFailure(string error) - { - lock (_healthLock) - { - _totalFailures++; - _lastFailureTime = DateTime.UtcNow; - _consecutiveFailures++; - _lastError = error; - } - } - - private void EnsureConnected() - { - if (_disposed) - throw new ObjectDisposedException(nameof(HistorianDataSource)); - - if (Volatile.Read(ref _connection) != null) return; - - var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process); - - lock (_connectionLock) - { - if (_disposed) - { - conn.CloseConnection(out _); - conn.Dispose(); - throw new ObjectDisposedException(nameof(HistorianDataSource)); - } - - if (_connection != null) - { - conn.CloseConnection(out _); - conn.Dispose(); - return; - } - - _connection = conn; - lock (_healthLock) _activeProcessNode = winningNode; - Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port); - } - } - - private void HandleConnectionError(Exception? ex = null) - { - lock (_connectionLock) - { - if (_connection == null) return; - - try - { - _connection.CloseConnection(out _); - _connection.Dispose(); - } - catch (Exception disposeEx) - { - Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery"); - } - - _connection = null; - string? failedNode; - lock (_healthLock) - { - failedNode = _activeProcessNode; - _activeProcessNode = null; - } - - if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure"); - Log.Warning(ex, "Historian SDK connection reset (node={Node})", failedNode ?? "(unknown)"); - } - } - - private void EnsureEventConnected() - { - if (_disposed) - throw new ObjectDisposedException(nameof(HistorianDataSource)); - - if (Volatile.Read(ref _eventConnection) != null) return; - - var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event); - - lock (_eventConnectionLock) - { - if (_disposed) - { - conn.CloseConnection(out _); - conn.Dispose(); - throw new ObjectDisposedException(nameof(HistorianDataSource)); - } - - if (_eventConnection != null) - { - conn.CloseConnection(out _); - conn.Dispose(); - return; - } - - _eventConnection = conn; - lock (_healthLock) _activeEventNode = winningNode; - Log.Information("Historian SDK event connection opened to {Server}:{Port}", winningNode, _config.Port); - } - } - - /// - /// Internal exception signalling that StartQuery returned an SDK error - /// whose code is query-class (bad tag name, unsupported aggregate, etc.) - /// and the shared SDK connection therefore must NOT be reset. The outer catch - /// re-throws this so the IPC frame handler surfaces Success=false without - /// touching the connection. See Driver.Historian.Wonderware-008. - /// - internal sealed class QueryClassStartQueryException : InvalidOperationException - { - /// Gets the error code that caused the exception. - public HistorianAccessError.ErrorValue Code { get; } - /// Initializes a new instance of the class. - /// The exception message. - /// The historian access error code. - public QueryClassStartQueryException(string message, HistorianAccessError.ErrorValue code) - : base(message) - { - Code = code; - } - } - - /// - /// Centralised StartQuery-failure handler. Throws so the caller surfaces - /// Success=false in the IPC reply (the previous return-empty-with-success - /// behaviour made an SDK error look like "no data in range" to the client). The - /// connection is only reset when the error code is connection-class — - /// query-class failures (bad tag name, unsupported aggregate, etc.) must leave - /// the shared SDK connection intact, otherwise a burst of bad-tag queries cycles - /// the connection and pushes a healthy cluster node into cooldown. - /// See Driver.Historian.Wonderware-008. - /// - private void HandleStartQueryFailure( - string operation, HistorianAccessError error, bool isEventConnection) - { - var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure; - var description = error?.ErrorDescription ?? string.Empty; - var connectionClass = IsConnectionClassError(code); - - Log.Warning( - "Historian SDK StartQuery failed: {Operation} -> {Code} ({Desc}) [{Kind}]", - operation, code, description, - connectionClass ? "connection-class" : "query-class"); - RecordFailure($"{operation}: {code}"); - - var message = $"Historian SDK StartQuery failed for {operation}: {code} ({description})"; - - if (connectionClass) - { - if (isEventConnection) HandleEventConnectionError(); - else HandleConnectionError(); - throw new InvalidOperationException(message); - } - - // Query-class — the outer catch block must NOT call HandleConnectionError on this. - throw new QueryClassStartQueryException(message, code); - } - - private void HandleEventConnectionError(Exception? ex = null) - { - lock (_eventConnectionLock) - { - if (_eventConnection == null) return; - - try - { - _eventConnection.CloseConnection(out _); - _eventConnection.Dispose(); - } - catch (Exception disposeEx) - { - Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery"); - } - - _eventConnection = null; - string? failedNode; - lock (_healthLock) - { - failedNode = _activeEventNode; - _activeEventNode = null; - } - - if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure"); - Log.Warning(ex, "Historian SDK event connection reset (node={Node})", failedNode ?? "(unknown)"); - } - } - - /// Reads raw historical samples for the specified tag. - /// The tag name. - /// The start time for the query. - /// The end time for the query. - /// The maximum number of values to return. - /// Cancellation token for the operation. - public Task> ReadRawAsync( - string tagName, DateTime startTime, DateTime endTime, int maxValues, - CancellationToken ct = default) - { - var results = new List(); - - // Driver.Historian.Wonderware-010: wire RequestTimeoutSeconds into the read path - // so a hung StartQuery / slow MoveNext can't block the TCP connection thread forever. - using var requestCts = BuildRequestCts(_config, ct); - var token = requestCts.Token; - - try - { - EnsureConnected(); - - using var query = _connection!.CreateHistoryQuery(); - var args = new HistoryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = startTime, - EndDateTime = endTime, - RetrievalMode = HistorianRetrievalMode.Full - }; - - if (maxValues > 0) - args.BatchSize = (uint)maxValues; - else if (_config.MaxValuesPerRead > 0) - args.BatchSize = (uint)_config.MaxValuesPerRead; - - if (!query.StartQuery(args, out var error)) - { - HandleStartQueryFailure( - $"raw query for tag '{tagName}'", error, isEventConnection: false); - } - - var count = 0; - var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead; - - while (query.MoveNext(out error)) - { - token.ThrowIfCancellationRequested(); - - var result = query.QueryResult; - var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc); - - results.Add(new HistorianSample - { - Value = SelectValue(result), - TimestampUtc = timestamp, - Quality = (byte)(result.OpcQuality & 0xFF), - }); - - count++; - if (limit > 0 && count >= limit) break; - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) { throw; } - catch (ObjectDisposedException) { throw; } - catch (QueryClassStartQueryException) - { - // Query-class StartQuery failure — HandleStartQueryFailure already logged - // and recorded. Re-throw so the IPC layer surfaces Success=false instead of - // returning an empty list (which would look like "no data in range"). The - // connection is deliberately NOT reset. See Driver.Historian.Wonderware-008. - throw; - } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName); - RecordFailure($"raw: {ex.Message}"); - HandleConnectionError(ex); - throw; - } - - Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})", - tagName, results.Count, startTime, endTime); - - return Task.FromResult(results); - } - - /// Reads aggregate historical samples for the specified tag. - /// The tag name. - /// The start time for the query. - /// The end time for the query. - /// The interval in milliseconds. - /// The aggregate column name. - /// Cancellation token for the operation. - public Task> ReadAggregateAsync( - string tagName, DateTime startTime, DateTime endTime, - double intervalMs, string aggregateColumn, - CancellationToken ct = default) - { - var results = new List(); - - // Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync. - using var requestCts = BuildRequestCts(_config, ct); - var token = requestCts.Token; - - try - { - EnsureConnected(); - - using var query = _connection!.CreateAnalogSummaryQuery(); - var args = new AnalogSummaryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = startTime, - EndDateTime = endTime, - Resolution = (ulong)intervalMs - }; - - if (!query.StartQuery(args, out var error)) - { - HandleStartQueryFailure( - $"aggregate query for tag '{tagName}'", error, isEventConnection: false); - } - - // Apply the same bucket cap as the raw-read path so a wide time range with a - // small IntervalMs cannot produce an unbounded result set that would overflow - // the 16 MiB FrameWriter frame cap and lose the entire reply. - var bucketLimit = _config.MaxValuesPerRead; - var bucketCount = 0; - - while (query.MoveNext(out error)) - { - token.ThrowIfCancellationRequested(); - - var result = query.QueryResult; - var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc); - var value = ExtractAggregateValue(result, aggregateColumn); - - results.Add(new HistorianAggregateSample - { - Value = value, - TimestampUtc = timestamp, - }); - - bucketCount++; - if (bucketLimit > 0 && bucketCount >= bucketLimit) - { - Log.Warning( - "HistoryRead aggregate ({Aggregate}): {Tag} truncated at {Limit} buckets — widen IntervalMs or reduce time range", - aggregateColumn, tagName, bucketLimit); - break; - } - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) { throw; } - catch (ObjectDisposedException) { throw; } - catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName); - RecordFailure($"aggregate: {ex.Message}"); - HandleConnectionError(ex); - throw; - } - - Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values", - aggregateColumn, tagName, results.Count); - - return Task.FromResult(results); - } - - /// Reads historical samples at specific timestamps for the specified tag. - /// The tag name. - /// The timestamps to read. - /// Cancellation token for the operation. - public Task> ReadAtTimeAsync( - string tagName, DateTime[] timestamps, - CancellationToken ct = default) - { - var results = new List(); - - if (timestamps == null || timestamps.Length == 0) - return Task.FromResult(results); - - // Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync. - using var requestCts = BuildRequestCts(_config, ct); - var token = requestCts.Token; - - try - { - EnsureConnected(); - - foreach (var timestamp in timestamps) - { - token.ThrowIfCancellationRequested(); - - using var query = _connection!.CreateHistoryQuery(); - var args = new HistoryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = timestamp, - EndDateTime = timestamp, - RetrievalMode = HistorianRetrievalMode.Interpolated, - BatchSize = 1 - }; - - if (!query.StartQuery(args, out var error)) - { - // Driver.Historian.Wonderware-014: classify the failure like the raw / - // aggregate / event paths. A connection-class code means the shared - // connection is dead — throw so the whole at-time read aborts and the IPC - // layer surfaces Success=false (the outer catch resets the connection and - // marks the node failed). Without this, every remaining timestamp would - // re-fail StartQuery on the dead connection and the method would still - // report Success=true with an all-Bad result, never failing over. A - // query-class / no-data code keeps the connection and records a Bad sample - // for just this timestamp. - if (ShouldResetConnectionForStartQueryFailure(error)) - { - var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure; - throw new InvalidOperationException( - $"Historian SDK StartQuery failed for at-time query of tag '{tagName}': {code} ({error?.ErrorDescription})"); - } - - results.Add(new HistorianSample - { - Value = null, - TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc), - Quality = 0, // Bad - }); - continue; - } - - if (query.MoveNext(out error)) - { - var result = query.QueryResult; - results.Add(new HistorianSample - { - Value = SelectValue(result), - TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc), - Quality = (byte)(result.OpcQuality & 0xFF), - }); - } - else - { - results.Add(new HistorianSample - { - Value = null, - TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc), - Quality = 0, - }); - } - - query.EndQuery(out _); - } - RecordSuccess(); - } - catch (OperationCanceledException) { throw; } - catch (ObjectDisposedException) { throw; } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName); - RecordFailure($"at-time: {ex.Message}"); - HandleConnectionError(ex); - throw; - } - - Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps", - tagName, results.Count, timestamps.Length); - - return Task.FromResult(results); - } - - /// Reads historical events within the specified time range. - /// The optional event source name filter. - /// The start time for the query. - /// The end time for the query. - /// The maximum number of events to return. - /// Cancellation token for the operation. - public Task> ReadEventsAsync( - string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, - CancellationToken ct = default) - { - var results = new List(); - - // Driver.Historian.Wonderware-010: outer safety timeout — see ReadRawAsync. - using var requestCts = BuildRequestCts(_config, ct); - var token = requestCts.Token; - - try - { - EnsureEventConnected(); - - using var query = _eventConnection!.CreateEventQuery(); - var args = new EventQueryArgs - { - StartDateTime = startTime, - EndDateTime = endTime, - EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead, - QueryType = HistorianEventQueryType.Events, - EventOrder = HistorianEventOrder.Ascending - }; - - if (!string.IsNullOrEmpty(sourceName)) - { - query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _); - } - - if (!query.StartQuery(args, out var error)) - { - HandleStartQueryFailure( - $"event query for source '{sourceName ?? "(all)"}'", error, isEventConnection: true); - } - - var count = 0; - while (query.MoveNext(out error)) - { - token.ThrowIfCancellationRequested(); - results.Add(ToDto(query.QueryResult)); - count++; - if (maxEvents > 0 && count >= maxEvents) break; - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) { throw; } - catch (ObjectDisposedException) { throw; } - catch (QueryClassStartQueryException) { throw; } // see ReadRawAsync — keep connection - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)"); - RecordFailure($"events: {ex.Message}"); - HandleEventConnectionError(ex); - throw; - } - - Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})", - sourceName ?? "(all)", results.Count, startTime, endTime); - - return Task.FromResult(results); - } - - private static HistorianEventDto ToDto(HistorianEvent evt) - { - // The ArchestrA SDK marks these properties obsolete but still returns them; their - // successors aren't wired in the version we bind against. Using them is the documented - // v1 behavior — suppressed locally instead of project-wide so any non-event use of - // deprecated SDK surface still surfaces as an error. -#pragma warning disable CS0618 - return new HistorianEventDto - { - Id = evt.Id, - Source = evt.Source, - EventTime = evt.EventTime, - ReceivedTime = evt.ReceivedTime, - DisplayText = evt.DisplayText, - Severity = (ushort)evt.Severity - }; -#pragma warning restore CS0618 - } - - /// - /// Selects the typed value from a row. - /// - /// SDK limitation: HistoryQueryResult exposes only Value - /// (double) and StringValue (string) — there is no tag data-type field on - /// the result. The correct approach would be to branch on the tag's declared - /// data type, but the bound version of aahClientManaged does not surface - /// it per query result. The heuristic below is the best available: prefer - /// StringValue only when it is non-empty AND Value is zero, - /// because string tags in the Historian SDK always project to Value=0 - /// while numeric tags may legitimately sample to zero (in which case the SDK - /// does not populate StringValue). A numeric tag at exactly zero with a - /// non-empty formatted StringValue (e.g. "0.00") would be mis-reported - /// as a string; this is a known edge case of the SDK binding. - /// - /// - /// The history query result. - internal static object? SelectValue(HistoryQueryResult result) - => SelectValueFromPair(result.Value, result.StringValue); - - /// - /// SDK-independent overload of the string-vs-numeric heuristic. Exposed so unit - /// tests can pin the logic without having to instantiate the SDK - /// (whose internal property initialisers make - /// it impractical to fake). See Driver.Historian.Wonderware-012. - /// - /// The numeric value. - /// The string value. - internal static object? SelectValueFromPair(double value, string? stringValue) - { - if (!string.IsNullOrEmpty(stringValue) && value == 0) - return stringValue; - return value; - } - - /// Extracts the specified aggregate value from an analog summary query result. - /// The analog summary query result. - /// The aggregate column name. - internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column) - { - switch (column) - { - case "Average": return result.Average; - case "Minimum": return result.Minimum; - case "Maximum": return result.Maximum; - case "ValueCount": return result.ValueCount; - case "First": return result.First; - case "Last": return result.Last; - case "StdDev": return result.StdDev; - default: return null; - } - } - - /// Disposes the historian data source and releases its resources. - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - _connection?.CloseConnection(out _); - _connection?.Dispose(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error closing Historian SDK connection"); - } - - try - { - _eventConnection?.CloseConnection(out _); - _eventConnection?.Dispose(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error closing Historian SDK event connection"); - } - - _connection = null; - _eventConnection = null; - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianEventDto.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianEventDto.cs deleted file mode 100644 index 94f4c0cb..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianEventDto.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// SDK-free representation of a Historian event record. Prevents ArchestrA types from - /// leaking beyond HistorianDataSource. - /// - public sealed class HistorianEventDto - { - /// Gets or sets the unique identifier for the event. - public Guid Id { get; set; } - - /// Gets or sets the source of the event. - public string? Source { get; set; } - - /// Gets or sets the time when the event occurred. - public DateTime EventTime { get; set; } - - /// Gets or sets the time when the event was received. - public DateTime ReceivedTime { get; set; } - - /// Gets or sets the display text for the event. - public string? DisplayText { get; set; } - - /// Gets or sets the severity level of the event. - public ushort Severity { get; set; } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianHealthSnapshot.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianHealthSnapshot.cs deleted file mode 100644 index 232bfeef..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianHealthSnapshot.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Point-in-time runtime health of the historian subsystem — consumed by the status dashboard - /// via an IPC health query (not wired in PR #5; deferred). - /// - public sealed class HistorianHealthSnapshot - { - /// Gets or sets the total number of queries executed. - public long TotalQueries { get; set; } - /// Gets or sets the total number of successful queries. - public long TotalSuccesses { get; set; } - /// Gets or sets the total number of failed queries. - public long TotalFailures { get; set; } - /// Gets or sets the number of consecutive failures. - public int ConsecutiveFailures { get; set; } - /// Gets or sets the time of the last successful query. - public DateTime? LastSuccessTime { get; set; } - /// Gets or sets the time of the last failed query. - public DateTime? LastFailureTime { get; set; } - /// Gets or sets the last error message, if any. - public string? LastError { get; set; } - /// Gets or sets a value indicating whether the process connection is open. - public bool ProcessConnectionOpen { get; set; } - /// Gets or sets a value indicating whether the event connection is open. - public bool EventConnectionOpen { get; set; } - /// Gets or sets the name of the active process node. - public string? ActiveProcessNode { get; set; } - /// Gets or sets the name of the active event node. - public string? ActiveEventNode { get; set; } - /// Gets or sets the total number of cluster nodes. - public int NodeCount { get; set; } - /// Gets or sets the number of healthy cluster nodes. - public int HealthyNodeCount { get; set; } - /// Gets or sets the list of cluster node states. - public List Nodes { get; set; } = new(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianQualityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianQualityMapper.cs deleted file mode 100644 index 0e5020ca..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianQualityMapper.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -/// -/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's OpcQuality) -/// to an OPC UA StatusCode uint. Preserves specific codes (BadNotConnected, -/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories. -/// Mirrors v1 QualityMapper.MapToOpcUaStatusCode without pulling in OPC UA types — -/// the returned value is the 32-bit OPC UA StatusCode wire encoding that the Proxy -/// surfaces directly as DataValueSnapshot.StatusCode. -/// -public static class HistorianQualityMapper -{ - /// - /// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte - /// family bits decide the category (Good >= 192, Uncertain 64-191, Bad 0-63); the - /// low-nibble subcode selects the specific code. - /// - /// The OPC DA quality byte. - /// The corresponding OPC UA status code. - public static uint Map(byte q) => q switch - { - // Good family (192+) - 192 => 0x00000000u, // Good - 216 => 0x00D80000u, // Good_LocalOverride - - // Uncertain family (64-191) - 64 => 0x40000000u, // Uncertain - 68 => 0x40900000u, // Uncertain_LastUsableValue - 80 => 0x40930000u, // Uncertain_SensorNotAccurate - 84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded - 88 => 0x40950000u, // Uncertain_SubNormal - - // Bad family (0-63) - 0 => 0x80000000u, // Bad - 4 => 0x80890000u, // Bad_ConfigurationError - 8 => 0x808A0000u, // Bad_NotConnected - 12 => 0x808B0000u, // Bad_DeviceFailure - 16 => 0x808C0000u, // Bad_SensorFailure - 20 => 0x80050000u, // Bad_CommunicationError - 24 => 0x808D0000u, // Bad_OutOfService - 32 => 0x80320000u, // Bad_WaitingForInitialData - - // Unknown code — fall back to the category so callers still get a sensible bucket. - _ when q >= 192 => 0x00000000u, - _ when q >= 64 => 0x40000000u, - _ => 0x80000000u, - }; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianSample.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianSample.cs deleted file mode 100644 index 51478c41..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/HistorianSample.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// OPC-UA-free representation of a single historical data point. The sidecar serialises - /// these onto the TCP wire (HistorianSampleDto) for the .NET 10 - /// WonderwareHistorianClient, which maps quality and value into OPC UA - /// DataValue on its side. Raw OPC DA quality byte is preserved so the client - /// can reuse the same quality mapper it already uses for live reads. - /// - public sealed class HistorianSample - { - /// Gets or sets the historical data value. - public object? Value { get; set; } - - /// Gets or sets the raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality). - public byte Quality { get; set; } - - /// Gets or sets the UTC timestamp of the historical sample. - public DateTime TimestampUtc { get; set; } - } - - /// - /// Result of . When is - /// null the aggregate is unavailable for that bucket — the client maps to BadNoData. - /// - public sealed class HistorianAggregateSample - { - /// Gets or sets the aggregate value, or null if unavailable. - public double? Value { get; set; } - /// Gets or sets the UTC timestamp of the aggregate sample. - public DateTime TimestampUtc { get; set; } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IAlarmHistorianWriteBackend.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IAlarmHistorianWriteBackend.cs deleted file mode 100644 index 3c655a15..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IAlarmHistorianWriteBackend.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// The actual aahClientManaged-bound writer. Extracted so unit tests can - /// substitute a fake without touching the SDK; the production - /// implementation lives in . - /// - /// - /// Implementations are responsible for connection management + cluster - /// failover. The wrapping - /// handles batch-level orchestration but delegates the per-event SDK call - /// here so the unit tests can drive every documented MxStatus outcome - /// without an installed AVEVA Historian. - /// - public interface IAlarmHistorianWriteBackend - { - /// - /// Persist the supplied events to the historian. Returns one outcome per - /// input slot in the same order — must always return an array of the same - /// length as . - /// - /// The events to write to the historian. - /// Token to cancel the operation. - Task WriteBatchAsync( - AlarmHistorianEventDto[] events, - CancellationToken cancellationToken); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianConnectionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianConnectionFactory.cs deleted file mode 100644 index 6b71c8b3..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianConnectionFactory.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Threading; -using ArchestrA; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Creates and opens Historian SDK connections. Extracted so tests can inject fakes that - /// control connection success, failure, and timeout behavior. - /// - internal interface IHistorianConnectionFactory - { - /// - /// Opens a Historian SDK connection. defaults to - /// true for the query path; the alarm-event write backend passes - /// false because HistorianAccess.AddStreamedValue fails with - /// WriteToReadOnlyFile on a read-only session. - /// - /// The historian configuration. - /// The type of connection to create. - /// Whether the connection should be read-only. - /// An open HistorianAccess connection. - HistorianAccess CreateAndConnect( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true); - } - - /// Production implementation — opens real Historian SDK connections. - internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory - { - /// Creates and connects a Historian SDK connection. - /// The historian configuration. - /// The type of connection to create. - /// Whether the connection should be read-only. - /// An open HistorianAccess connection. - public HistorianAccess CreateAndConnect( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) - { - var conn = new HistorianAccess(); - var args = BuildConnectionArgs(config, type, readOnly); - - if (!conn.OpenConnection(args, out var error)) - { - conn.Dispose(); - throw new InvalidOperationException( - $"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}"); - } - - var timeoutMs = config.CommandTimeoutSeconds * 1000; - var elapsed = 0; - while (elapsed < timeoutMs) - { - var status = new HistorianConnectionStatus(); - conn.GetConnectionStatus(ref status); - - if (status.ConnectedToServer) - return conn; - - if (status.ErrorOccurred) - { - conn.Dispose(); - throw new InvalidOperationException( - $"Historian SDK connection failed: {status.Error}"); - } - - Thread.Sleep(250); - elapsed += 250; - } - - conn.Dispose(); - throw new TimeoutException( - $"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s"); - } - - /// - /// Builds the for a connection. Pure (no SDK - /// side effects) so the read-only-vs-write argument shaping is unit-testable. - /// - /// The historian configuration. - /// The type of connection to create. - /// Whether the connection should be read-only. - /// The configured connection arguments. - internal static HistorianConnectionArgs BuildConnectionArgs( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly) - { - var args = new HistorianConnectionArgs - { - ServerName = config.ServerName, - TcpPort = (ushort)config.Port, - IntegratedSecurity = config.IntegratedSecurity, - UseArchestrAUser = config.IntegratedSecurity, - ConnectionType = type, - ReadOnly = readOnly, - PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000) - }; - - if (!config.IntegratedSecurity) - { - args.UserName = config.UserName ?? string.Empty; - args.Password = config.Password ?? string.Empty; - } - - return args; - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianDataSource.cs deleted file mode 100644 index 8900909d..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/IHistorianDataSource.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// OPC-UA-free surface for the Wonderware Historian subsystem inside the historian - /// sidecar process. Implementations read via the aahClient* SDK; the .NET 10 - /// WonderwareHistorianClient on the other side of the TCP IPC maps - /// returned samples to OPC UA DataValue. The v1 Galaxy.Host / Proxy hosts - /// this lived in retired in PR 7.2. - /// - public interface IHistorianDataSource : IDisposable - { - /// Reads raw historical samples asynchronously. - /// The tag name to read from. - /// The start time of the time range. - /// The end time of the time range. - /// The maximum number of values to return. - /// The cancellation token. - /// A task representing the asynchronous operation that returns a list of historian samples. - Task> ReadRawAsync( - string tagName, DateTime startTime, DateTime endTime, int maxValues, - CancellationToken ct = default); - - /// Reads aggregate historical samples asynchronously. - /// The tag name to read from. - /// The start time of the time range. - /// The end time of the time range. - /// The interval in milliseconds for aggregation. - /// The column to aggregate. - /// The cancellation token. - /// A task representing the asynchronous operation that returns a list of aggregate samples. - Task> ReadAggregateAsync( - string tagName, DateTime startTime, DateTime endTime, - double intervalMs, string aggregateColumn, - CancellationToken ct = default); - - /// Reads historical samples at specific times asynchronously. - /// The tag name to read from. - /// The array of timestamps at which to read values. - /// The cancellation token. - /// A task representing the asynchronous operation that returns a list of historian samples. - Task> ReadAtTimeAsync( - string tagName, DateTime[] timestamps, - CancellationToken ct = default); - - /// Reads historical events asynchronously. - /// The source name to filter events, or null for all sources. - /// The start time of the time range. - /// The end time of the time range. - /// The maximum number of events to return. - /// The cancellation token. - /// A task representing the asynchronous operation that returns a list of historian events. - Task> ReadEventsAsync( - string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, - CancellationToken ct = default); - - /// Gets a health snapshot of the data source. - /// A HistorianHealthSnapshot containing the current health information. - HistorianHealthSnapshot GetHealthSnapshot(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/SdkAlarmHistorianWriteBackend.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/SdkAlarmHistorianWriteBackend.cs deleted file mode 100644 index c1a2a9b6..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/SdkAlarmHistorianWriteBackend.cs +++ /dev/null @@ -1,398 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend -{ - /// - /// Production backed by AVEVA Historian's - /// aahClientManaged SDK. Each is written via - /// HistorianAccess.AddStreamedValue(HistorianEvent, out HistorianAccessError) — - /// the alarm-event write entry point pinned during PR C.1. - /// - /// - /// - /// The write path needs its own connection. The query-side - /// opens ReadOnly sessions, and - /// AddStreamedValue on a read-only session fails with - /// WriteToReadOnlyFile. This backend therefore opens a dedicated - /// ReadOnly = false connection; it shares - /// for node selection and failover but - /// not the connection object itself. - /// - /// - /// Per-event HistorianAccessError.ErrorValue codes map onto - /// via - /// . A connection-class - /// error aborts the remainder of the batch as - /// and resets the connection so - /// the next drain tick reconnects — possibly to a different cluster node. - /// - /// - /// The exact HistorianEvent field set required by the Historian is confirmed - /// against a live install during the PR D.1 rollout smoke; - /// maps the unambiguous fields and carries operator comment / condition id as event - /// properties. - /// - /// - public sealed class SdkAlarmHistorianWriteBackend : IAlarmHistorianWriteBackend, IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - // ErrorValue codes that mean the connection/server is the problem (transient) rather - // than the event payload. These abort the rest of the batch and trigger a reconnect. - private static readonly HashSet ConnectionErrors = - new HashSet - { - HistorianAccessError.ErrorValue.FailedToConnect, - HistorianAccessError.ErrorValue.FailedToCreateSession, - HistorianAccessError.ErrorValue.NoReply, - HistorianAccessError.ErrorValue.NotReady, - HistorianAccessError.ErrorValue.NotInitialized, - HistorianAccessError.ErrorValue.Stopping, - HistorianAccessError.ErrorValue.Win32Exception, - HistorianAccessError.ErrorValue.InvalidResponse, - // WriteToReadOnlyFile is a connection-configuration fault, not an event-payload - // fault: the session was opened without ReadOnly = false (a misconfiguration or - // a regression). The event itself is fine, so it must NOT be dead-lettered. - // Classifying it here aborts the batch and resets the connection so the - // reconnect path re-opens a writable (ReadOnly = false) session; the deferred - // events drain on the next tick. See Driver.Historian.Wonderware-001. - HistorianAccessError.ErrorValue.WriteToReadOnlyFile, - }; - - // ErrorValue codes that mean the event itself is malformed — permanent, never retried. - private static readonly HashSet MalformedErrors = - new HashSet - { - HistorianAccessError.ErrorValue.InvalidArgument, - HistorianAccessError.ErrorValue.ValidationFailed, - HistorianAccessError.ErrorValue.NullPointerArgument, - HistorianAccessError.ErrorValue.NotImplemented, - HistorianAccessError.ErrorValue.NotApplicable, - }; - - private readonly HistorianConfiguration _config; - private readonly IHistorianConnectionFactory _factory; - private readonly HistorianClusterEndpointPicker _picker; - private readonly object _connectionLock = new object(); - private HistorianAccess? _connection; - private string? _activeNode; - private bool _disposed; - - /// Initializes a new instance using the default SDK connection factory. - /// The historian configuration. - public SdkAlarmHistorianWriteBackend(HistorianConfiguration config) - : this(config, new SdkHistorianConnectionFactory(), null) { } - - /// Initializes a new instance with injected dependencies (for testing). - /// The historian configuration. - /// The connection factory. - /// The cluster endpoint picker, or null to use a new instance. - internal SdkAlarmHistorianWriteBackend( - HistorianConfiguration config, - IHistorianConnectionFactory factory, - HistorianClusterEndpointPicker? picker = null) - { - _config = config ?? throw new ArgumentNullException(nameof(config)); - _factory = factory ?? throw new ArgumentNullException(nameof(factory)); - _picker = picker ?? new HistorianClusterEndpointPicker(config); - } - - /// Writes a batch of alarm events to the historian, returning outcomes for each event. - /// The alarm events to write. - /// The cancellation token. - /// An array of outcomes corresponding to each input event. - public Task WriteBatchAsync( - AlarmHistorianEventDto[] events, - CancellationToken cancellationToken) - { - if (events is null || events.Length == 0) - { - return Task.FromResult(new AlarmHistorianWriteOutcome[0]); - } - - var outcomes = new AlarmHistorianWriteOutcome[events.Length]; - - HistorianAccess connection; - try - { - connection = EnsureConnected(); - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - // No reachable node — defer the whole batch so the lmxopcua-side SQLite - // store-and-forward sink retains the rows for the next drain tick. - Log.Warning(ex, - "Alarm historian write connection unavailable — deferring {Count} event(s) as RetryPlease", - events.Length); - FillRemaining(outcomes, 0, AlarmHistorianWriteOutcome.RetryPlease); - return Task.FromResult(outcomes); - } - - for (var i = 0; i < events.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var historianEvent = ToHistorianEvent(events[i]); - if (connection.AddStreamedValue(historianEvent, out var error)) - { - outcomes[i] = AlarmHistorianWriteOutcome.Ack; - continue; - } - - var code = error?.ErrorCode ?? HistorianAccessError.ErrorValue.Failure; - if (ConnectionErrors.Contains(code)) - { - // Connection died mid-batch — drop it and defer this event + the rest. - Log.Warning( - "Alarm historian write hit connection-level error {Code} ({Desc}); resetting connection, deferring {Remaining} event(s)", - code, error?.ErrorDescription, events.Length - i); - HandleConnectionError(error?.ErrorDescription); - FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease); - return Task.FromResult(outcomes); - } - - outcomes[i] = ClassifyOutcome(code); - Log.Warning( - "Alarm historian write rejected event {EventId}: {Code} ({Desc}) -> {Outcome}", - events[i].EventId, code, error?.ErrorDescription, outcomes[i]); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - // Transport-level throw (SDK marshalling fault, broken connection) — - // reset and defer this event + the rest. - Log.Warning(ex, - "Alarm historian write threw for event {EventId}; resetting connection, deferring {Remaining} event(s)", - events[i].EventId, events.Length - i); - HandleConnectionError(ex.Message); - FillRemaining(outcomes, i, AlarmHistorianWriteOutcome.RetryPlease); - return Task.FromResult(outcomes); - } - } - - return Task.FromResult(outcomes); - } - - /// - /// Maps an onto the SDK's - /// HistorianEvent. Operator comment and originating condition id ride as - /// event properties — operator-comment fidelity is the field the value-driven - /// fallback path cannot carry. - /// - /// The alarm event data transfer object. - /// The mapped HistorianEvent. - internal static HistorianEvent ToHistorianEvent(AlarmHistorianEventDto dto) - { - // The ArchestrA SDK marks these HistorianEvent members obsolete but still honours - // them on write; their successors aren't wired in the version we bind against. - // Using them is the documented v1 behaviour — mirrors HistorianDataSource.ToDto, - // suppressed locally so any other deprecated-surface use still surfaces as an error. -#pragma warning disable CS0618 - var historianEvent = new HistorianEvent - { - IsAlarm = true, - Source = dto.SourceName ?? string.Empty, - EventType = string.IsNullOrEmpty(dto.AlarmType) ? "Alarm" : dto.AlarmType, - EventTime = new DateTime(dto.EventTimeUtcTicks, DateTimeKind.Utc), - ReceivedTime = DateTime.UtcNow, - Severity = dto.Severity, - DisplayText = dto.Message ?? string.Empty, - }; - - if (Guid.TryParse(dto.EventId, out var id)) - { - historianEvent.Id = id; - } - else - { - // Driver.Historian.Wonderware-004: an unparseable / empty EventId previously - // left Id as Guid.Empty, which made every such alarm collide on the same id - // with no diagnostic. Synthesize a fresh Guid so each event still gets a - // unique identifier (the historian still accepts the write — outcome stays - // Ack — and the sender can correlate the synthesized id via the warning log). - var synthesized = Guid.NewGuid(); - Log.Warning( - "Alarm historian event has non-parseable EventId {EventId} for source {Source}; synthesizing Id={SynthesizedId}", - dto.EventId ?? "(null)", dto.SourceName ?? "(none)", synthesized); - historianEvent.Id = synthesized; - } -#pragma warning restore CS0618 - - if (!string.IsNullOrEmpty(dto.AckComment)) - { - historianEvent.AddProperty("Comment", dto.AckComment, out _); - } - if (!string.IsNullOrEmpty(dto.ConditionId)) - { - historianEvent.AddProperty("ConditionId", dto.ConditionId, out _); - } - - return historianEvent; - } - - /// - /// Classifies a non-connection-class HistorianAccessError.ErrorValue into an - /// by routing it through the shared - /// mapping. Exposed for - /// unit tests — connection-class codes are handled separately by the batch loop. - /// - /// The error code to classify. - /// The corresponding write outcome. - internal static AlarmHistorianWriteOutcome ClassifyOutcome(HistorianAccessError.ErrorValue code) - => AahClientManagedAlarmEventWriter.MapOutcome( - (int)code, - isCommunicationError: ConnectionErrors.Contains(code), - isMalformedInput: MalformedErrors.Contains(code)); - - private static void FillRemaining( - AlarmHistorianWriteOutcome[] outcomes, int from, AlarmHistorianWriteOutcome value) - { - for (var i = from; i < outcomes.Length; i++) - { - outcomes[i] = value; - } - } - - private HistorianAccess EnsureConnected() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend)); - } - - var existing = Volatile.Read(ref _connection); - if (existing != null) return existing; - - var (conn, node) = ConnectToAnyHealthyNode(); - - lock (_connectionLock) - { - if (_disposed) - { - SafeClose(conn); - throw new ObjectDisposedException(nameof(SdkAlarmHistorianWriteBackend)); - } - - if (_connection != null) - { - SafeClose(conn); - return _connection; - } - - _connection = conn; - _activeNode = node; - Log.Information("Alarm historian write connection opened to {Server}:{Port}", node, _config.Port); - return conn; - } - } - - private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode() - { - var candidates = _picker.GetHealthyNodes(); - if (candidates.Count == 0) - { - throw new InvalidOperationException( - _picker.NodeCount == 0 - ? "No historian nodes configured" - : $"All {_picker.NodeCount} historian nodes are in cooldown — no healthy endpoints"); - } - - Exception? lastException = null; - foreach (var node in candidates) - { - try - { - var conn = _factory.CreateAndConnect( - CloneConfigWithServerName(node), HistorianConnectionType.Event, readOnly: false); - _picker.MarkHealthy(node); - return (conn, node); - } - catch (Exception ex) - { - _picker.MarkFailed(node, ex.Message); - lastException = ex; - Log.Warning(ex, "Alarm historian node {Node} failed during write-connect; trying next", node); - } - } - - throw new InvalidOperationException( - $"All {candidates.Count} healthy historian candidate(s) failed during write-connect: " + - (lastException?.Message ?? "(no detail)"), - lastException); - } - - private void HandleConnectionError(string? detail) - { - lock (_connectionLock) - { - if (_connection == null) return; - - SafeClose(_connection); - _connection = null; - - var failedNode = _activeNode; - _activeNode = null; - if (failedNode != null) _picker.MarkFailed(failedNode, detail ?? "mid-batch failure"); - Log.Warning("Alarm historian write connection reset (node={Node})", failedNode ?? "(unknown)"); - } - } - - private static void SafeClose(HistorianAccess conn) - { - try - { - conn.CloseConnection(out _); - conn.Dispose(); - } - catch (Exception ex) - { - Log.Debug(ex, "Error closing alarm historian write connection"); - } - } - - private HistorianConfiguration CloneConfigWithServerName(string serverName) => new HistorianConfiguration - { - Enabled = _config.Enabled, - ServerName = serverName, - ServerNames = _config.ServerNames, - FailureCooldownSeconds = _config.FailureCooldownSeconds, - IntegratedSecurity = _config.IntegratedSecurity, - UserName = _config.UserName, - Password = _config.Password, - Port = _config.Port, - CommandTimeoutSeconds = _config.CommandTimeoutSeconds, - MaxValuesPerRead = _config.MaxValuesPerRead, - RequestTimeoutSeconds = _config.RequestTimeoutSeconds, - }; - - /// Disposes the connection and releases resources. - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - lock (_connectionLock) - { - if (_connection != null) - { - SafeClose(_connection); - _connection = null; - } - } - } - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs deleted file mode 100644 index 7a0211fa..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs +++ /dev/null @@ -1,270 +0,0 @@ -using System; -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -// ============================================================================ -// Wire DTOs for the sidecar TCP protocol. The sidecar speaks its own legacy -// shape (List etc.) — the .NET 10 client (PR 3.4) translates -// to / from Core.Abstractions.DataValueSnapshot + HistoricalEvent. -// -// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's -// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc). -// ============================================================================ - -/// Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode. -[MessagePackObject] -public sealed class HistorianSampleDto -{ - /// MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type. - [Key(0)] public byte[]? ValueBytes { get; set; } - - /// Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality). - [Key(1)] public byte Quality { get; set; } - - /// Gets or sets the timestamp in UTC ticks. - [Key(2)] public long TimestampUtcTicks { get; set; } -} - -/// Aggregate bucket; Value is null when the aggregate is unavailable for the bucket. -[MessagePackObject] -public sealed class HistorianAggregateSampleDto -{ - /// Gets or sets the aggregate value. - [Key(0)] public double? Value { get; set; } - - /// Gets or sets the timestamp in UTC ticks. - [Key(1)] public long TimestampUtcTicks { get; set; } -} - -/// Historian event row. -[MessagePackObject] -public sealed class HistorianEventDto -{ - /// Gets or sets the event identifier. - [Key(0)] public string EventId { get; set; } = string.Empty; - - /// Gets or sets the event source name. - [Key(1)] public string? Source { get; set; } - - /// Gets or sets the event time in UTC ticks. - [Key(2)] public long EventTimeUtcTicks { get; set; } - - /// Gets or sets the received time in UTC ticks. - [Key(3)] public long ReceivedTimeUtcTicks { get; set; } - - /// Gets or sets the display text. - [Key(4)] public string? DisplayText { get; set; } - - /// Gets or sets the severity. - [Key(5)] public ushort Severity { get; set; } -} - -/// Alarm event to persist back into the historian event store. -[MessagePackObject] -public sealed class AlarmHistorianEventDto -{ - /// Gets or sets the event identifier. - [Key(0)] public string EventId { get; set; } = string.Empty; - - /// Gets or sets the source name. - [Key(1)] public string SourceName { get; set; } = string.Empty; - - /// Gets or sets the condition identifier. - [Key(2)] public string? ConditionId { get; set; } - - /// Gets or sets the alarm type. - [Key(3)] public string AlarmType { get; set; } = string.Empty; - - /// Gets or sets the alarm message. - [Key(4)] public string? Message { get; set; } - - /// Gets or sets the severity. - [Key(5)] public ushort Severity { get; set; } - - /// Gets or sets the event time in UTC ticks. - [Key(6)] public long EventTimeUtcTicks { get; set; } - - /// Gets or sets the acknowledgment comment. - [Key(7)] public string? AckComment { get; set; } -} - -// ===== Read Raw ===== - -[MessagePackObject] -public sealed class ReadRawRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - - /// Gets or sets the maximum number of values to return. - [Key(3)] public int MaxValues { get; set; } - - /// Gets or sets the correlation identifier. - [Key(4)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadRawReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether the request succeeded. - [Key(1)] public bool Success { get; set; } - - /// Gets or sets the error message if the request failed. - [Key(2)] public string? Error { get; set; } - - /// Gets or sets the historical samples. - [Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty(); -} - -// ===== Read Processed ===== - -[MessagePackObject] -public sealed class ReadProcessedRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - - /// Gets or sets the interval in milliseconds. - [Key(3)] public double IntervalMs { get; set; } - - /// - /// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount". - /// The .NET 10 client maps OPC UA aggregate enum → column. - /// - [Key(4)] public string AggregateColumn { get; set; } = string.Empty; - - /// Gets or sets the correlation identifier. - [Key(5)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadProcessedReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether the request succeeded. - [Key(1)] public bool Success { get; set; } - - /// Gets or sets the error message if the request failed. - [Key(2)] public string? Error { get; set; } - - /// Gets or sets the aggregate sample buckets. - [Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty(); -} - -// ===== Read At-Time ===== - -[MessagePackObject] -public sealed class ReadAtTimeRequest -{ - /// Gets or sets the tag name. - [Key(0)] public string TagName { get; set; } = string.Empty; - - /// Gets or sets the timestamps in UTC ticks. - [Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty(); - - /// Gets or sets the correlation identifier. - [Key(2)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadAtTimeReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether the request succeeded. - [Key(1)] public bool Success { get; set; } - - /// Gets or sets the error message if the request failed. - [Key(2)] public string? Error { get; set; } - - /// Gets or sets the historical samples. - [Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty(); -} - -// ===== Read Events ===== - -[MessagePackObject] -public sealed class ReadEventsRequest -{ - /// Gets or sets the source name. - [Key(0)] public string? SourceName { get; set; } - - /// Gets or sets the start time in UTC ticks. - [Key(1)] public long StartUtcTicks { get; set; } - - /// Gets or sets the end time in UTC ticks. - [Key(2)] public long EndUtcTicks { get; set; } - - /// Gets or sets the maximum number of events to return. - [Key(3)] public int MaxEvents { get; set; } - - /// Gets or sets the correlation identifier. - [Key(4)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class ReadEventsReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether the request succeeded. - [Key(1)] public bool Success { get; set; } - - /// Gets or sets the error message if the request failed. - [Key(2)] public string? Error { get; set; } - - /// Gets or sets the historian events. - [Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty(); -} - -// ===== Write Alarm Events ===== - -[MessagePackObject] -public sealed class WriteAlarmEventsRequest -{ - /// Gets or sets the alarm events to write. - [Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty(); - - /// Gets or sets the correlation identifier. - [Key(1)] public string CorrelationId { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class WriteAlarmEventsReply -{ - /// Gets or sets the correlation identifier. - [Key(0)] public string CorrelationId { get; set; } = string.Empty; - - /// Gets or sets a value indicating whether the request succeeded. - [Key(1)] public bool Success { get; set; } - - /// Gets or sets the error message if the request failed. - [Key(2)] public string? Error { get; set; } - - /// Per-event success flag, parallel to . - [Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty(); - - /// Per-event status parallel to the request's Events: 0=Ack, 1=Retry, 2=Permanent. - /// Empty ⇒ an older sidecar that only sent ; the client falls back to it. - [Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty(); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameReader.cs deleted file mode 100644 index 7593a408..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameReader.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call -/// from multiple threads against the same instance. Mirror of -/// Driver.Galaxy.Shared.FrameReader; sidecar carries its own copy so the deletion of -/// Galaxy.Shared in PR 7.2 doesn't reach the sidecar. -/// -public sealed class FrameReader : IDisposable -{ - private readonly Stream _stream; - private readonly bool _leaveOpen; - - /// Initializes a new instance of the class. - /// The stream to read frames from. - /// Whether to leave the stream open when disposing. - public FrameReader(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - /// Reads the next frame asynchronously from the stream. - /// Cancellation token for the operation. - /// A tuple of message kind and body, or null if EOF is encountered cleanly. - public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct) - { - var lengthPrefix = new byte[Framing.LengthPrefixSize]; - if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false)) - return null; // clean EOF on frame boundary - - var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3]; - if (length < 0 || length > Framing.MaxFrameBodyBytes) - throw new InvalidDataException($"Sidecar IPC frame length {length} out of range."); - - var kindByte = _stream.ReadByte(); - if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte."); - - var body = new byte[length]; - if (!await ReadExactAsync(body, ct).ConfigureAwait(false)) - throw new EndOfStreamException("EOF mid-frame."); - - return ((MessageKind)(byte)kindByte, body); - } - - /// Deserializes the message body to the specified type. - /// The type to deserialize to. - /// The serialized message body. - public static T Deserialize(byte[] body) => MessagePackSerializer.Deserialize(body); - - private async Task ReadExactAsync(byte[] buffer, CancellationToken ct) - { - var offset = 0; - while (offset < buffer.Length) - { - var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false); - if (read == 0) - { - if (offset == 0) return false; - throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes."); - } - offset += read; - } - return true; - } - - /// Disposes the frame reader and optionally closes the underlying stream. - public void Dispose() - { - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameWriter.cs deleted file mode 100644 index fd88a7e7..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/FrameWriter.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via -/// so concurrent producers (heartbeat + reply paths) get -/// serialized writes. Mirror of Driver.Galaxy.Shared.FrameWriter; sidecar carries its -/// own copy. -/// -public sealed class FrameWriter : IDisposable -{ - private readonly Stream _stream; - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly bool _leaveOpen; - - /// Initializes a new instance of the FrameWriter. - /// The stream to write frames to. - /// Whether to leave the stream open when disposed. - public FrameWriter(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - /// Writes a frame with the specified message kind and serialized message. - /// The type of message being written. - /// The message kind identifier. - /// The message to serialize and write. - /// The cancellation token. - public async Task WriteAsync(MessageKind kind, T message, CancellationToken ct) - { - var body = MessagePackSerializer.Serialize(message, cancellationToken: ct); - if (body.Length > Framing.MaxFrameBodyBytes) - throw new InvalidOperationException( - $"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap."); - - var lengthPrefix = new byte[Framing.LengthPrefixSize]; - // Big-endian — easy to read in hex dumps. - lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF); - lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF); - lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF); - lengthPrefix[3] = (byte)( body.Length & 0xFF); - - await _gate.WaitAsync(ct).ConfigureAwait(false); - try - { - await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false); - _stream.WriteByte((byte)kind); - await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false); - await _stream.FlushAsync(ct).ConfigureAwait(false); - } - finally { _gate.Release(); } - } - - /// Disposes the frame writer and releases resources. - public void Dispose() - { - _gate.Dispose(); - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Framing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Framing.cs deleted file mode 100644 index bcc51b6b..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Framing.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Length-prefixed framing constants for the Wonderware historian sidecar TCP protocol. -/// Each frame on the wire is: -/// [4-byte big-endian length][1-byte message kind][MessagePack body]. -/// Length is the body size only; the kind byte is not part of the prefixed length. -/// -/// -/// Mirrors the Galaxy.Shared framing exactly so the same FrameReader/FrameWriter pattern -/// works on both sides. The sidecar's protocol is independent — both the .NET 4.8 server -/// side and the .NET 10 client (PR 3.4) carry their own copies of these constants and -/// stay in sync via the round-trip test matrix. -/// -public static class Framing -{ - public const int LengthPrefixSize = 4; - public const int KindByteSize = 1; - - /// 16 MiB cap protects the receiver from a hostile or buggy peer. - public const int MaxFrameBodyBytes = 16 * 1024 * 1024; -} - -/// -/// Wire identifier for each historian sidecar message. Values are stable — never reorder; -/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must -/// agree on every value here. -/// -public enum MessageKind : byte -{ - Hello = 0x01, - HelloAck = 0x02, - - ReadRawRequest = 0x10, - ReadRawReply = 0x11, - - ReadProcessedRequest = 0x12, - ReadProcessedReply = 0x13, - - ReadAtTimeRequest = 0x14, - ReadAtTimeReply = 0x15, - - ReadEventsRequest = 0x16, - ReadEventsReply = 0x17, - - WriteAlarmEventsRequest = 0x20, - WriteAlarmEventsReply = 0x21, -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Hello.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Hello.cs deleted file mode 100644 index 4fb56b79..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Hello.cs +++ /dev/null @@ -1,41 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// First frame of every connection. Advertises the sidecar protocol version and the -/// per-process shared secret the supervisor passed at spawn time. -/// -[MessagePackObject] -public sealed class Hello -{ - public const int CurrentMajor = 1; - public const int CurrentMinor = 0; - - /// Gets or sets the protocol major version. - [Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor; - /// Gets or sets the protocol minor version. - [Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor; - /// Gets or sets the peer name. - [Key(2)] public string PeerName { get; set; } = string.Empty; - - /// Per-process shared secret — verified against the value the supervisor passed at spawn time. - [Key(3)] public string SharedSecret { get; set; } = string.Empty; -} - -/// Response to a Hello handshake message. -[MessagePackObject] -public sealed class HelloAck -{ - /// Gets or sets the protocol major version. - [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor; - /// Gets or sets the protocol minor version. - [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor; - - /// Gets or sets a value indicating whether the handshake was accepted. - [Key(2)] public bool Accepted { get; set; } - /// Gets or sets the rejection reason if Accepted is false. - [Key(3)] public string? RejectReason { get; set; } - /// Gets or sets the host name of the server. - [Key(4)] public string HostName { get; set; } = string.Empty; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs deleted file mode 100644 index 95cc047a..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs +++ /dev/null @@ -1,334 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Sidecar-side dispatcher. Each post-Hello frame routes by to -/// the right historian operation and the result frame is written back through the same -/// pipe. Per-call exceptions are caught and surfaced as Success=false, Error=... -/// replies so a single bad request doesn't kill the connection. -/// -public sealed class HistorianFrameHandler : IFrameHandler -{ - // WriteAlarmEventsReply.PerEventStatus byte semantics: 0=Ack, 1=Retry, 2=Permanent. - private const byte StatusAck = 0; - private const byte StatusRetry = 1; - private const byte StatusPermanent = 2; - - private readonly IHistorianDataSource _historian; - private readonly IAlarmEventWriter? _alarmWriter; - private readonly ILogger _logger; - - /// Initializes a new instance of the HistorianFrameHandler class. - /// The historian data source to query. - /// The logger instance. - /// Optional alarm event writer for writebacks. - public HistorianFrameHandler( - IHistorianDataSource historian, - ILogger logger, - IAlarmEventWriter? alarmWriter = null) - { - _historian = historian ?? throw new ArgumentNullException(nameof(historian)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _alarmWriter = alarmWriter; - } - - /// Handles an incoming frame by dispatching to the appropriate historian operation. - /// The frame message kind. - /// The frame body bytes. - /// The frame writer for sending responses. - /// Cancellation token. - public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) - => kind switch - { - MessageKind.ReadRawRequest => HandleReadRawAsync(body, writer, ct), - MessageKind.ReadProcessedRequest => HandleReadProcessedAsync(body, writer, ct), - MessageKind.ReadAtTimeRequest => HandleReadAtTimeAsync(body, writer, ct), - MessageKind.ReadEventsRequest => HandleReadEventsAsync(body, writer, ct), - MessageKind.WriteAlarmEventsRequest => HandleWriteAlarmEventsAsync(body, writer, ct), - _ => UnknownAsync(kind), - }; - - private Task UnknownAsync(MessageKind kind) - { - _logger.Warning("Sidecar received unsupported frame kind {Kind}; dropping", kind); - return Task.CompletedTask; - } - - private async Task HandleReadRawAsync(byte[] body, FrameWriter writer, CancellationToken ct) - { - var req = MessagePackSerializer.Deserialize(body); - var reply = new ReadRawReply { CorrelationId = req.CorrelationId }; - try - { - var samples = await _historian.ReadRawAsync( - req.TagName, - new DateTime(req.StartUtcTicks, DateTimeKind.Utc), - new DateTime(req.EndUtcTicks, DateTimeKind.Utc), - req.MaxValues, - ct).ConfigureAwait(false); - - reply.Success = true; - reply.Samples = ToWire(samples); - } - catch (Exception ex) - { - _logger.Warning(ex, "Sidecar ReadRaw failed for {Tag}", req.TagName); - reply.Success = false; - reply.Error = ex.Message; - } - - await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct).ConfigureAwait(false); - } - - private async Task HandleReadProcessedAsync(byte[] body, FrameWriter writer, CancellationToken ct) - { - var req = MessagePackSerializer.Deserialize(body); - var reply = new ReadProcessedReply { CorrelationId = req.CorrelationId }; - try - { - var buckets = await _historian.ReadAggregateAsync( - req.TagName, - new DateTime(req.StartUtcTicks, DateTimeKind.Utc), - new DateTime(req.EndUtcTicks, DateTimeKind.Utc), - req.IntervalMs, - req.AggregateColumn, - ct).ConfigureAwait(false); - - reply.Success = true; - reply.Buckets = ToWire(buckets); - } - catch (Exception ex) - { - _logger.Warning(ex, "Sidecar ReadProcessed failed for {Tag}", req.TagName); - reply.Success = false; - reply.Error = ex.Message; - } - - await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct).ConfigureAwait(false); - } - - private async Task HandleReadAtTimeAsync(byte[] body, FrameWriter writer, CancellationToken ct) - { - var req = MessagePackSerializer.Deserialize(body); - var reply = new ReadAtTimeReply { CorrelationId = req.CorrelationId }; - try - { - var timestamps = new DateTime[req.TimestampsUtcTicks.Length]; - for (var i = 0; i < timestamps.Length; i++) - timestamps[i] = new DateTime(req.TimestampsUtcTicks[i], DateTimeKind.Utc); - - var samples = await _historian.ReadAtTimeAsync(req.TagName, timestamps, ct).ConfigureAwait(false); - reply.Success = true; - reply.Samples = ToWire(samples); - } - catch (Exception ex) - { - _logger.Warning(ex, "Sidecar ReadAtTime failed for {Tag}", req.TagName); - reply.Success = false; - reply.Error = ex.Message; - } - - await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct).ConfigureAwait(false); - } - - private async Task HandleReadEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct) - { - var req = MessagePackSerializer.Deserialize(body); - var reply = new ReadEventsReply { CorrelationId = req.CorrelationId }; - try - { - var events = await _historian.ReadEventsAsync( - req.SourceName, - new DateTime(req.StartUtcTicks, DateTimeKind.Utc), - new DateTime(req.EndUtcTicks, DateTimeKind.Utc), - req.MaxEvents, - ct).ConfigureAwait(false); - - reply.Success = true; - reply.Events = ToWire(events); - } - catch (Exception ex) - { - _logger.Warning(ex, "Sidecar ReadEvents failed for source {Source}", req.SourceName); - reply.Success = false; - reply.Error = ex.Message; - } - - await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct).ConfigureAwait(false); - } - - private async Task HandleWriteAlarmEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct) - { - var req = MessagePackSerializer.Deserialize(body); - - // MessagePack deserializes an absent or explicit-nil array as null, not Array.Empty. - // Normalise here so every path below can safely dereference .Length without an NRE. - req.Events ??= Array.Empty(); - - var reply = new WriteAlarmEventsReply { CorrelationId = req.CorrelationId }; - - if (_alarmWriter is null) - { - reply.Success = false; - reply.Error = "Sidecar not configured with an alarm-event writer."; - reply.PerEventOk = new bool[req.Events.Length]; - reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry); - await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false); - return; - } - - try - { - // Classify each event before touching the writer: structurally-malformed - // (poison) events can never be persisted, so mark them Permanent and exclude - // them from the writer batch. Only the well-formed remainder is handed to the - // writer, whose bool[] result is mapped back onto the original indices. - var status = new byte[req.Events.Length]; - var writable = new List(req.Events.Length); - var originalIndex = new List(req.Events.Length); - for (var i = 0; i < req.Events.Length; i++) - { - if (IsStructurallyMalformed(req.Events[i])) - { - status[i] = StatusPermanent; - } - else - { - originalIndex.Add(i); - writable.Add(req.Events[i]); - } - } - - // Aligned 1:1 to `writable`; empty when every event was poison (writer skipped). - var perEvent = writable.Count == 0 - ? Array.Empty() - : await _alarmWriter.WriteAsync(writable.ToArray(), ct).ConfigureAwait(false); - - for (var i = 0; i < originalIndex.Count; i++) - { - var ok = i < perEvent.Length && perEvent[i]; - status[originalIndex[i]] = ok ? StatusAck : StatusRetry; - } - - reply.PerEventStatus = status; - reply.PerEventOk = StatusToOk(status); - reply.Success = true; - // Whole-batch Success stays true even when some events failed — per-event - // PerEventStatus slots carry the granular result (Ack / Retry / Permanent); - // the SQLite drain worker acks 0, retries 1, and dead-letters 2. PerEventOk - // is kept populated for rolling-deploy back-compat with an older client. - } - catch (Exception ex) - { - _logger.Warning(ex, "Sidecar WriteAlarmEvents failed"); - reply.Success = false; - reply.Error = ex.Message; - reply.PerEventOk = new bool[req.Events.Length]; - reply.PerEventStatus = AllStatus(req.Events.Length, StatusRetry); - } - - await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false); - } - - /// - /// Classifies an alarm event as structurally malformed (poison): an event the historian - /// event store can never persist regardless of retries. Such events are marked Permanent - /// so the store-and-forward sink dead-letters them immediately instead of looping to the - /// retry cap. A blank source name or alarm type, or a non-positive event timestamp, are - /// the structural invariants the historian write requires. - /// - /// The candidate alarm event. - /// true when the event is structurally malformed; otherwise false. - internal static bool IsStructurallyMalformed(AlarmHistorianEventDto e) => - e is null - || string.IsNullOrWhiteSpace(e.SourceName) - || string.IsNullOrWhiteSpace(e.AlarmType) - || e.EventTimeUtcTicks <= 0; - - private static byte[] AllStatus(int length, byte value) - { - var status = new byte[length]; - for (var i = 0; i < length; i++) status[i] = value; - return status; - } - - private static bool[] StatusToOk(byte[] status) - { - var ok = new bool[status.Length]; - for (var i = 0; i < status.Length; i++) ok[i] = status[i] == StatusAck; - return ok; - } - - private static HistorianSampleDto[] ToWire(List samples) - { - var dtos = new HistorianSampleDto[samples.Count]; - for (var i = 0; i < samples.Count; i++) - { - var s = samples[i]; - dtos[i] = new HistorianSampleDto - { - ValueBytes = s.Value is null ? null : MessagePackSerializer.Serialize(s.Value), - Quality = s.Quality, - TimestampUtcTicks = s.TimestampUtc.Ticks, - }; - } - return dtos; - } - - private static HistorianAggregateSampleDto[] ToWire(List samples) - { - var dtos = new HistorianAggregateSampleDto[samples.Count]; - for (var i = 0; i < samples.Count; i++) - { - dtos[i] = new HistorianAggregateSampleDto - { - Value = samples[i].Value, - TimestampUtcTicks = samples[i].TimestampUtc.Ticks, - }; - } - return dtos; - } - - private static HistorianEventDto[] ToWire(List events) - { - var dtos = new HistorianEventDto[events.Count]; - for (var i = 0; i < events.Count; i++) - { - var e = events[i]; - dtos[i] = new HistorianEventDto - { - EventId = e.Id.ToString(), - Source = e.Source, - EventTimeUtcTicks = e.EventTime.Ticks, - ReceivedTimeUtcTicks = e.ReceivedTime.Ticks, - DisplayText = e.DisplayText, - Severity = e.Severity, - }; - } - return dtos; - } -} - -/// -/// Strategy for persisting alarm events into the Wonderware Alarm & Events log. PR 3.W -/// supplies a real implementation that drives the aahClient SDK; PR 3.3 ships the -/// contract + a default null implementation so the sidecar can boot without one. -/// -public interface IAlarmEventWriter -{ - /// - /// Writes a batch of alarm events. Returns one boolean per input event indicating - /// persisted vs. retry-please. The SQLite store-and-forward sink retries failed - /// slots on the next drain tick. - /// - /// Alarm events to write. - /// Cancellation token. - Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs deleted file mode 100644 index e00e606c..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Strategy for handling each post-Hello frame the sidecar's -/// reads. Implementations deserialize the body per the , dispatch -/// to the historian, and write the corresponding reply through the supplied -/// . -/// -public interface IFrameHandler -{ - /// Handles a frame from the sidecar frame server. - /// The type of message being handled. - /// The serialized message body. - /// The frame writer to send responses. - /// Cancellation token for the operation. - Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct); -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs deleted file mode 100644 index a253c9f6..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -/// -/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret -/// Hello, then dispatches frames to . Authentication is the -/// shared secret carried in the Hello frame, optionally over a TLS-protected channel. -/// -public sealed class TcpFrameServer : IDisposable -{ - private readonly IPAddress _bind; - private readonly int _port; - private readonly string _sharedSecret; - private readonly X509Certificate2? _tlsCert; // null = plaintext - private readonly ILogger _logger; - private readonly CancellationTokenSource _cts = new(); - private TcpListener? _listener; - - /// Initializes a new instance of the class. - /// The IP address to bind the listener to. - /// The TCP port to bind (0 lets the OS pick a free port). - /// The shared secret the client's Hello must match. - /// The server certificate for TLS; null for plaintext. - /// The logger for diagnostic messages. - public TcpFrameServer(IPAddress bind, int port, string sharedSecret, X509Certificate2? tlsCert, ILogger logger) - { - _bind = bind ?? throw new ArgumentNullException(nameof(bind)); - _port = port; - _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret)); - _tlsCert = tlsCert; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// The port the listener actually bound (useful when constructed with port 0 in tests). - public int BoundPort => ((IPEndPoint)_listener!.LocalEndpoint).Port; - - private void EnsureListening() - { - if (_listener is not null) return; - - // Assign _listener ONLY after Start() succeeds. If Start() throws (e.g. the port is in - // a Windows excluded/reserved range → WSAEACCES "access forbidden", or already in use), - // _listener must stay null so the next RunAsync iteration retries the full create+Start. - // Assigning before Start() leaves a non-null-but-unstarted listener that the - // `if (_listener is not null) return` guard would never re-Start, turning a one-time - // bind error into a permanent misleading "Not listening" crash loop. - var listener = new TcpListener(_bind, _port); - listener.Start(); - _listener = listener; - } - - /// - /// Accepts one connection, performs the Hello handshake, then dispatches frames to - /// until EOF or cancel. Returns when the client disconnects. - /// - /// The frame handler to process frames. - /// Cancellation token for the operation. - public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct); - EnsureListening(); - - // net48 has no AcceptTcpClientAsync(CancellationToken); Stop() unblocks a pending accept. - using var reg = linked.Token.Register(() => { try { _listener!.Stop(); } catch { /* ignore */ } }); - TcpClient client; - try { client = await _listener!.AcceptTcpClientAsync().ConfigureAwait(false); } - catch (ObjectDisposedException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); } - catch (InvalidOperationException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); } - catch (SocketException) when (linked.Token.IsCancellationRequested) { throw new OperationCanceledException(linked.Token); } - - using (client) - { - // net48's NetworkStream.ReadAsync ignores the CancellationToken, so cancelling the - // token alone cannot unblock the frame loop when it's parked reading an idle client — - // only closing the socket does. Register the cancel to Close() the active client so - // RunAsync actually unwinds on shutdown (mirrors the listener.Stop() above that - // unblocks a parked AcceptTcpClientAsync). Without this, RunAsync().GetAwaiter() in - // Program.Main never returns on Ctrl-C/service-stop while a connection is open. - using var clientReg = linked.Token.Register(() => { try { client.Close(); } catch { /* ignore */ } }); - client.NoDelay = true; - Stream stream = client.GetStream(); - SslStream? ssl = null; - try - { - if (_tlsCert is not null) - { - ssl = new SslStream(stream, leaveInnerStreamOpen: false); - await ssl.AuthenticateAsServerAsync(_tlsCert, clientCertificateRequired: false, - enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false); - stream = ssl; - } - - using var reader = new FrameReader(stream, leaveOpen: true); - using var writer = new FrameWriter(stream, leaveOpen: true); - - var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); - if (first is null || first.Value.Kind != MessageKind.Hello) - { - _logger.Warning("Sidecar TCP first frame was not Hello; dropping"); - return; - } - var hello = MessagePackSerializer.Deserialize(first.Value.Body); - if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal)) - { - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, linked.Token).ConfigureAwait(false); - _logger.Warning("Sidecar TCP Hello rejected: shared-secret-mismatch"); - return; - } - if (hello.ProtocolMajor != Hello.CurrentMajor) - { - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" }, - linked.Token).ConfigureAwait(false); - _logger.Warning("Sidecar TCP Hello rejected: major mismatch peer={Peer} server={Server}", hello.ProtocolMajor, Hello.CurrentMajor); - return; - } - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = true, HostName = Environment.MachineName }, linked.Token).ConfigureAwait(false); - - while (!linked.Token.IsCancellationRequested) - { - var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); - if (frame is null) break; - await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false); - } - } - catch (Exception) when (linked.Token.IsCancellationRequested) - { - // The clientReg cancel callback closed the socket mid-read/handshake (net48 read - // doesn't observe the token); surface it as cancellation so RunAsync's - // OperationCanceledException path unwinds cleanly instead of logging a connection - // failure and counting it toward MaxConsecutiveFailures. - throw new OperationCanceledException(linked.Token); - } - finally { ssl?.Dispose(); } - } - } - - // ---- exponential backoff / give-up policy between accepted connections ---- - private static readonly TimeSpan[] BackoffSteps = - { - TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), - }; - - /// - /// Maximum consecutive failures before the server gives up and lets the process exit - /// so the supervisor (NSSM / SCM) can restart the sidecar cleanly. - /// - private const int MaxConsecutiveFailures = 20; - - /// - /// Runs the server continuously, handling one connection at a time. When a connection - /// ends (clean or error), waits with exponential backoff before accepting the next. - /// If consecutive failures occur the method - /// throws so the supervisor can restart the sidecar. - /// - /// The frame handler to process frames. - /// Cancellation token for the operation. - public async Task RunAsync(IFrameHandler handler, CancellationToken ct) - { - var consecutiveFailures = 0; - while (!ct.IsCancellationRequested) - { - try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); consecutiveFailures = 0; } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - consecutiveFailures++; - if (consecutiveFailures >= MaxConsecutiveFailures) - { - _logger.Fatal(ex, "Sidecar TCP connection loop failed {Count} consecutive times — giving up so supervisor can restart", consecutiveFailures); - throw; - } - var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)]; - _logger.Error(ex, "Sidecar TCP connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}", consecutiveFailures, MaxConsecutiveFailures, delay); - try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } - } - } - } - - /// Disposes the server, stops the listener, and cancels any pending operations. - public void Dispose() { _cts.Cancel(); try { _listener?.Stop(); } catch { /* ignore */ } _cts.Dispose(); } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs deleted file mode 100644 index cd2088b3..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System; -using System.Net; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware; - -/// -/// Entry point for the Wonderware Historian sidecar. Reads the shared secret, TCP -/// bind/port, optional TLS settings, and historian connection config from environment -/// (the supervisor passes them at spawn time per driver-stability.md). Hosts a -/// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to -/// the Wonderware Historian SDK. -/// -public static class Program -{ - /// Entry point for the Wonderware Historian sidecar process. - /// Command-line arguments (unused). - /// 0 on success, 2 on fatal error. - public static int Main(string[] args) - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.File( - @"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)), - rollingInterval: RollingInterval.Day) - .CreateLogger(); - - try - { - var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET") - ?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time"); - - var tcpPort = TryParseInt("OTOPCUA_HISTORIAN_TCP_PORT", 32569); - var bindRaw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_BIND"); - var bind = string.IsNullOrWhiteSpace(bindRaw) ? IPAddress.Any : IPAddress.Parse(bindRaw); - var tlsEnabled = string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_ENABLED"), "true", StringComparison.OrdinalIgnoreCase); - X509Certificate2? tlsCert = tlsEnabled ? LoadTlsCert() : null; - - using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; - - // Sidecar can boot in "tcp idle" mode (no real Wonderware Historian SDK - // initialization) for smoke + IPC tests. Production sets ENABLED=true so the - // SDK opens its connection up front. - var historianEnabled = string.Equals( - Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"), - "true", StringComparison.OrdinalIgnoreCase); - - if (!historianEnabled) - { - Log.Information("Wonderware historian sidecar starting in tcp idle mode (SDK disabled) (OTOPCUA_HISTORIAN_ENABLED!=true) — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null); - cts.Token.WaitHandle.WaitOne(); - Log.Information("Wonderware historian sidecar stopping cleanly"); - return 0; - } - - using var historian = BuildHistorian(); - var alarmWriter = BuildAlarmWriter(); - var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter); - using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger); - - Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null); - try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); } - catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ } - - Log.Information("Wonderware historian sidecar stopped cleanly"); - return 0; - } - catch (Exception ex) - { - Log.Fatal(ex, "Wonderware historian sidecar fatal"); - return 2; - } - finally { Log.CloseAndFlush(); } - } - - /// - /// Builds the Wonderware Historian data source from environment variables. Mirrors - /// the env-var contract that Driver.Galaxy.Host used in v1; PR 3.W reaffirms - /// this contract in install scripts. - /// - private static HistorianDataSource BuildHistorian() - { - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", - Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), - IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase), - UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"), - Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"), - CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30), - MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000), - FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60), - }; - - var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS"); - if (!string.IsNullOrWhiteSpace(servers)) - cfg.ServerNames = new System.Collections.Generic.List( - servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); - - Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}", - cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port); - return new HistorianDataSource(cfg); - } - - private static int TryParseInt(string envName, int defaultValue) - { - var raw = Environment.GetEnvironmentVariable(envName); - return int.TryParse(raw, out var parsed) ? parsed : defaultValue; - } - - /// - /// Loads the TLS server certificate when TLS is enabled. The reference is either a - /// .pfx file path (decrypted with the optional password env var) or, if not a - /// file, a thumbprint resolved from the LocalMachine\My store. - /// - private static X509Certificate2 LoadTlsCert() - { - var certRef = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT") - ?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_TLS_CERT not set but TLS enabled — supply a .pfx path or a LocalMachine\\My store thumbprint"); - var pwd = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD"); - if (System.IO.File.Exists(certRef)) - return new X509Certificate2(certRef, pwd, X509KeyStorageFlags.MachineKeySet); - // else treat as a thumbprint in LocalMachine\My - using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); - store.Open(OpenFlags.ReadOnly); - var found = store.Certificates.Find(X509FindType.FindByThumbprint, certRef.Replace(" ", ""), validOnly: false); - if (found.Count == 0) throw new InvalidOperationException($"OTOPCUA_HISTORIAN_TLS_CERT thumbprint '{certRef}' not found in LocalMachine\\My and is not a file path"); - return found[0]; - } - - /// - /// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise - /// returns null so falls back to the - /// "not configured" reply for any incoming WriteAlarmEvents frame. - /// Default is true when OTOPCUA_HISTORIAN_ENABLED=true; explicitly - /// set OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false to keep a read-only - /// deployment that still loads the SDK for reads. - /// - internal static IAlarmEventWriter? BuildAlarmWriter() - { - var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED"); - var enabled = string.IsNullOrWhiteSpace(raw) - ? true - : !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase); - - if (!enabled) - { - Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames."); - return null; - } - - var cfg = BuildAlarmWriterConfig(); - var backend = new SdkAlarmHistorianWriteBackend(cfg); - Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName); - return new AahClientManagedAlarmEventWriter(backend); - } - - private static HistorianConfiguration BuildAlarmWriterConfig() - { - return new HistorianConfiguration - { - Enabled = true, - ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", - Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), - IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase), - UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"), - Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"), - CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30), - FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60), - }; - } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj deleted file mode 100644 index b4d555b3..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj +++ /dev/null @@ -1,65 +0,0 @@ - - - - Exe - net48 - - x64 - enable - latest - true - true - $(NoWarn);CS1591 - ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware - OtOpcUa.Driver.Historian.Wonderware - - - - - - - - - - - - - - - - - - - ..\..\..\lib\aahClientManaged.dll - false - - - ..\..\..\lib\aahClientCommon.dll - false - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor index 45224b8a..5cccb3d2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor @@ -60,7 +60,6 @@ else ["Focas"] = typeof(FocasDriverPage), ["OpcUaClient"] = typeof(OpcUaClientDriverPage), ["GalaxyMxGateway"] = typeof(GalaxyDriverPage), - ["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage), }; protected override async Task OnInitializedAsync() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor index 9985605d..c678a61f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor @@ -45,6 +45,5 @@ new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."), new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."), new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."), - new DriverTypeEntry("Historian.Wonderware", "historianwonderware","[Hx]", "Wonderware Historian replay/cyclic reads."), }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor deleted file mode 100644 index 1b550668..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor +++ /dev/null @@ -1,367 +0,0 @@ -@page "/clusters/{ClusterId}/drivers/new/historianwonderware" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@rendermode RenderMode.InteractiveServer -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.EntityFrameworkCore -@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients -@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers -@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers -@using ZB.MOM.WW.OtOpcUa.Configuration -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client -@inject IDbContextFactory DbFactory -@inject NavigationManager Nav - -
-

@(IsNew ? "New Wonderware Historian driver" : "Edit Wonderware Historian driver") · @ClusterId

- Cancel -
- - -@if (!_loaded) -{ -

Loading…

-} -else if (!IsNew && _existing is null) -{ -
- Driver instance @DriverInstanceId was not found in cluster @ClusterId. -
-} -else -{ - - - - - - - @if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId)) - { - - } - -
- - -
- - - - - - @* Connection *@ -
-
Connection
-
-
-
- - -
DNS name or IP the historian sidecar's TCP listener is reachable at.
-
-
- - -
Must match the sidecar's OTOPCUA_HISTORIAN_TCP_PORT.
-
-
- - -
Per-process secret verified in the Hello frame — must match the sidecar's configured secret.
-
-
- - -
Sent in Hello for sidecar logging. Default: OtOpcUa.
-
-
- -
- - -
-
Wrap the sidecar TCP stream in TLS before the Hello handshake.
-
-
- - -
SHA-1 thumbprint to pin; blank = validate CA chain.
-
-
-
-
- - @* Timing *@ -
-
Timing
-
-
-
- - -
Cap on TCP connect + Hello round-trip. Null = 10 s.
-
-
- - -
Cap on a single read/write once connected. Null = 30 s.
-
-
- - -
-
- - -
-
-
-
- - @* Diagnostics *@ -
-
Diagnostics
-
-
-
- - -
Max 60. Used by Test Connect. Default 15.
-
-
-
-
- - -
-
-} - -@code { - [Parameter] public string ClusterId { get; set; } = ""; - [Parameter] public string? DriverInstanceId { get; set; } - - private const string DriverTypeKey = "Historian.Wonderware"; - - private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); - - private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() - { - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, - UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, - WriteIndented = false, - Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, - }; - - private FormModel _form = new(); - private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; - private DriverInstance? _existing; - private List _namespaces = new(); - private bool _loaded; - private bool _busy; - private string? _error; - - // Address picker state - private bool _showPicker; - private string _pickedAddress = ""; - - private void OnAddressPicked(string address) => _pickedAddress = address; - - protected override async Task OnInitializedAsync() - { - await using var db = await DbFactory.CreateDbContextAsync(); - _namespaces = await db.Namespaces.AsNoTracking() - .Where(n => n.ClusterId == ClusterId) - .OrderBy(n => n.NamespaceId) - .ToListAsync(); - - if (IsNew) - { - _identityModel = new() - { - DriverInstanceId = "", - Name = "", - DriverType = DriverTypeKey, - NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", - Enabled = true, - }; - _form = new FormModel(); - } - else - { - _existing = await db.DriverInstances.AsNoTracking() - .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); - if (_existing is not null) - { - _identityModel = new() - { - DriverInstanceId = _existing.DriverInstanceId, - Name = _existing.Name, - DriverType = _existing.DriverType, - NamespaceId = _existing.NamespaceId, - Enabled = _existing.Enabled, - }; - var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions(); - _form = new FormModel(); - _form.Historian = WonderwareHistorianClientFormModel.FromRecord(opts); - _form.ResilienceConfig = _existing.ResilienceConfig; - _form.RowVersion = _existing.RowVersion; - } - } - _loaded = true; - } - - private static WonderwareHistorianClientOptions CreateDefaultOptions() => - new(Host: "localhost", Port: 32569, SharedSecret: "") { UseTls = false, ServerCertThumbprint = null }; - - private async Task SubmitAsync() - { - _busy = true; _error = null; - try - { - var opts = _form.Historian.ToRecord(); - var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); - await using var db = await DbFactory.CreateDbContextAsync(); - if (IsNew) - { - if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) - { - _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; - } - db.DriverInstances.Add(new DriverInstance - { - DriverInstanceId = _identityModel.DriverInstanceId, - ClusterId = ClusterId, - NamespaceId = _identityModel.NamespaceId, - Name = _identityModel.Name, - DriverType = DriverTypeKey, - Enabled = _identityModel.Enabled, - DriverConfig = configJson, - ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, - }); - } - else - { - var entity = await db.DriverInstances.FirstOrDefaultAsync( - d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); - if (entity is null) { _error = "Row no longer exists."; return; } - db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; - entity.NamespaceId = _identityModel.NamespaceId; - entity.Name = _identityModel.Name; - entity.Enabled = _identityModel.Enabled; - entity.DriverConfig = configJson; - entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; - } - await db.SaveChangesAsync(); - Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); - } - catch (DbUpdateConcurrencyException) - { - _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; - } - catch (Exception ex) { _error = ex.Message; } - finally { _busy = false; } - } - - private async Task DeleteAsync() - { - if (IsNew) return; - _busy = true; _error = null; - try - { - await using var db = await DbFactory.CreateDbContextAsync(); - var entity = await db.DriverInstances.FirstOrDefaultAsync( - d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); - if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } - db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; - db.DriverInstances.Remove(entity); - await db.SaveChangesAsync(); - Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); - } - catch (DbUpdateConcurrencyException) - { - _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; - } - catch (Exception ex) - { - _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; - } - finally { _busy = false; } - } - - private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts); - - private static WonderwareHistorianClientOptions? TryDeserialize(string json) - { - try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } - catch { return null; } - } - - public sealed class FormModel - { - public WonderwareHistorianClientFormModel Historian { get; set; } = new(); - public string? ResilienceConfig { get; set; } - public byte[] RowVersion { get; set; } = []; - } - - /// - /// Mutable mirror of (positional record). - /// ConnectTimeoutSeconds and CallTimeoutSeconds are nullable int — null - /// round-trips to a null TimeSpan?, which the record resolves to its compiled default. - /// - public sealed class WonderwareHistorianClientFormModel - { - public string Host { get; set; } = "localhost"; - public int Port { get; set; } = 32569; - public string SharedSecret { get; set; } = ""; - public string PeerName { get; set; } = "OtOpcUa"; - public int? ConnectTimeoutSeconds { get; set; } - public int? CallTimeoutSeconds { get; set; } - public int ProbeTimeoutSeconds { get; set; } = 15; - public bool UseTls { get; set; } - public string? ServerCertThumbprint { get; set; } - - public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new() - { - Host = r.Host, - Port = r.Port, - SharedSecret = r.SharedSecret, - PeerName = r.PeerName, - ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null, - CallTimeoutSeconds = r.CallTimeout.HasValue ? (int)r.CallTimeout.Value.TotalSeconds : null, - ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, - UseTls = r.UseTls, - ServerCertThumbprint = r.ServerCertThumbprint, - }; - - public WonderwareHistorianClientOptions ToRecord() => new( - Host: Host, - Port: Port, - SharedSecret: SharedSecret, - PeerName: PeerName, - ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null, - CallTimeout: CallTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(CallTimeoutSeconds.Value) : null) - { - ProbeTimeoutSeconds = ProbeTimeoutSeconds, - UseTls = UseTls, - ServerCertThumbprint = ServerCertThumbprint, - }; - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor index f642687a..5b3a9039 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor @@ -36,7 +36,6 @@ -
Cannot be changed after creation — drives the actor type that owns this instance.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs deleted file mode 100644 index f797234c..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers; - -/// -/// Pure static helper that converts a Wonderware Historian tag name + retrieval mode -/// + interval into the canonical address query string (e.g. MyTag?mode=Cyclic&interval=60). -/// Extracted so unit tests can call it without bUnit. -/// -public static class HistorianWonderwareAddressBuilder -{ - public static string Build(string tagName, string mode, int interval) - // Percent-encode the tag name so a name carrying query-reserved characters (? & # =) can't - // corrupt the produced query string (AdminUI-005). Mode is a fixed enum-style token, so it - // needs no encoding. - => $"{Uri.EscapeDataString(tagName)}?mode={mode}&interval={interval}"; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressPickerBody.razor deleted file mode 100644 index ce1fc254..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/HistorianWonderwareAddressPickerBody.razor +++ /dev/null @@ -1,52 +0,0 @@ -@* Static Wonderware Historian address builder: tag name + retrieval mode + interval - → MyTag?mode=Cyclic&interval=60 *@ -@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers - -
-
- - -
-
- - -
-
- - -
Polling/retrieval interval.
-
-
- -
- Result: - @_built -
- -@code { - [Parameter] public string CurrentAddress { get; set; } = ""; - [Parameter] public EventCallback CurrentAddressChanged { get; set; } - - private string _tagName = ""; - private string _mode = "Cyclic"; - private int _interval = 60; - private string _built = ""; - - protected override void OnInitialized() - { - _built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval); - _ = CurrentAddressChanged.InvokeAsync(_built); - } - - private async Task OnChangedAsync() - { - _built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval); - await CurrentAddressChanged.InvokeAsync(_built); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor deleted file mode 100644 index 508a3082..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/HistorianWonderwareTagConfigEditor.razor +++ /dev/null @@ -1,32 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors - -
-
- -
The AVEVA Historian tagname the driver reads against.
-
- -@code { - [Parameter] public string? ConfigJson { get; set; } - [Parameter] public EventCallback ConfigJsonChanged { get; set; } - - private HistorianWonderwareTagConfigModel _m = new(); - private string? _lastConfigJson; - - // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render - // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. - protected override void OnParametersSet() - { - if (ConfigJson == _lastConfigJson) { return; } - _lastConfigJson = ConfigJson; - _m = HistorianWonderwareTagConfigModel.FromJson(ConfigJson); - } - - private async Task Update(Action apply) - { - apply(); - await ConfigJsonChanged.InvokeAsync(_m.ToJson()); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs deleted file mode 100644 index f80065bb..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/HistorianWonderwareTagConfigModel.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text.Json.Nodes; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; - -/// Typed working model for a Wonderware (AVEVA) Historian equipment tag's TagConfig JSON. The -/// tag binds to a historian tag by its full reference (FullName — the historian tagname/source -/// the driver reads against). Preserves unrecognised JSON keys across a load→save. -/// -/// The FullName key is intentionally PascalCase: the deploy-time composer + node walker -/// (AddressSpaceComposer.ExtractTagFullName, EquipmentNodeWalker) read it via a -/// case-sensitive TryGetProperty("FullName", …), so the editor MUST persist that exact -/// casing. The driver-agnostic server-side HistoryRead intent keys (isHistorized / -/// historianTagname) are NOT modelled here — they are owned by the TagModal-merge seam -/// () and survive a load→save of this model as preserved unknown keys. -/// -public sealed class HistorianWonderwareTagConfigModel -{ - /// Historian tagname/source the tag binds to (the driver-side full reference). Required. - public string FullName { get; set; } = ""; - - private JsonObject _bag = new(); - - /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining - /// every original key (so fields this editor doesn't expose survive a load→save). - /// The tag's TagConfig JSON (null/blank/malformed ⇒ defaults). - public static HistorianWonderwareTagConfigModel FromJson(string? json) - { - var o = TagConfigJson.ParseOrNew(json); - return new HistorianWonderwareTagConfigModel - { - FullName = TagConfigJson.GetString(o, "FullName") ?? "", - _bag = o, - }; - } - - /// Serialises this model back to a TagConfig JSON string over the preserved key bag. - /// FullName is written PascalCase (the composer/walker contract key); any history keys merged - /// by the TagModal (isHistorized / historianTagname) are carried through untouched as - /// preserved unknown keys. - public string ToJson() - { - TagConfigJson.Set(_bag, "FullName", FullName.Trim()); - return TagConfigJson.Serialize(_bag); - } - - /// Validation hook; returns an error message or null when the model is valid. - public string? Validate() - => string.IsNullOrWhiteSpace(FullName) ? "A historian tagname (FullName) is required." : null; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 7da79860..c6c9f153 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -17,7 +17,6 @@ public static class TagConfigEditorMap ["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor), ["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), ["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), - ["Historian.Wonderware"] = typeof(Components.Shared.Uns.TagEditors.HistorianWonderwareTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index f126a1cd..3037d2cd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -19,7 +19,6 @@ public static class TagConfigValidator ["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(), ["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(), ["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), - ["Historian.Wonderware"] = j => HistorianWonderwareTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index f4f9b142..f82ee26d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -36,7 +36,6 @@ - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index e61aa142..a24eb392 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -15,7 +15,6 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe; using FocasProbe = Driver.FOCAS.FocasDriverProbe; using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; -using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe; /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) @@ -84,7 +83,6 @@ public static class DriverFactoryBootstrap services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ba04ba4e..a3e6bfee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -99,9 +99,8 @@ if (hasDriver) // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) // with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path // targets the SAME single gateway as the read path, so its connection (endpoint/key/TLS) is sourced - // from the ServerHistorian section — NOT the legacy Wonderware-shaped AlarmHistorian host/port. - // AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs - // (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused. + // from the ServerHistorian section. AlarmHistorianOptions supplies only the Enabled gate + the + // SQLite store-and-forward knobs (consumed inside AddAlarmHistorian) — it carries no connection fields. // Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream // via the driver factory (which owns the package-client adapter). The writer builds its OWN gateway // channel — a second channel to the same sidecar: sharing one channel with the read path would force diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index 1c6275ec..e4898588 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -54,15 +54,14 @@ called from DriverFactoryBootstrap on driver-role nodes; the F7 seam (IDriverFactory) then exposes the registry to DriverHostActor. Galaxy is net10 because it talks gRPC to the out-of-process mxaccessgw worker — the COM-bound net48 piece is over there. - Historian.Wonderware (the net48 COM-bridge driver) is intentionally excluded; the - net10 .Client gRPC wrapper is what production binds when the historian role is needed. --> + The historian read/write backend is the Historian.Gateway driver (gRPC to HistorianGateway); + the retired Wonderware historian sidecar projects are no longer referenced. --> - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 37fc5ea3..c940c004 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -233,13 +233,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// /// Returns true when the driver should boot in DEV-STUB mode based on host platform and - /// configured roles. Only the v1 in-process types stay Windows-only: - /// - /// "Galaxy" — legacy MXAccess COM proxy (retired in PR 7.2; gated for any - /// leftover DriverInstance rows that still reference the old type name). - /// "Historian.Wonderware" — Wonderware Historian sidecar over Windows-only - /// named pipes. - /// + /// configured roles. Only the legacy v1 in-process "Galaxy" type stays Windows-only: + /// the legacy MXAccess COM proxy (retired in PR 7.2; gated for any leftover DriverInstance + /// rows that still reference the old type name). /// The v2 "GalaxyMxGateway" driver talks gRPC to an external mxaccessgw process, /// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed. /// @@ -247,7 +243,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Operational roles configured for this instance. public static bool ShouldStub(string driverType, IEnumerable roles) { - var isWindowsOnly = driverType is "Galaxy" or "Historian.Wonderware"; + var isWindowsOnly = driverType is "Galaxy"; if (!OperatingSystem.IsWindows() && isWindowsOnly) return true; if (roles.Contains("dev") && isWindowsOnly) return true; return false; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs index c01fc059..6bced110 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs @@ -8,8 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// Binds the AlarmHistorian configuration section that gates the durable /// store-and-forward alarm sink. When is true, /// AddAlarmHistorian registers a SqliteStoreAndForwardSink (draining to the -/// Wonderware TCP writer supplied by the Host) in place of the -/// NullAlarmHistorianSink default; otherwise the Null default survives. +/// gateway alarm writer supplied by the Host) in place of the +/// NullAlarmHistorianSink default; otherwise the Null default survives. This section +/// supplies only the gate and the SQLite store-and-forward knobs — the +/// downstream connection (endpoint/key/TLS) is sourced from the ServerHistorian section. /// public sealed class AlarmHistorianOptions { @@ -25,21 +27,6 @@ public sealed class AlarmHistorianOptions /// Filesystem path to the local SQLite store-and-forward queue database. public string DatabasePath { get; init; } = "alarm-historian.db"; - /// TCP hostname or IP address the Wonderware historian sidecar listens on. - public string Host { get; init; } = "localhost"; - - /// TCP port the Wonderware historian sidecar listens on. - public int Port { get; init; } = 32569; - - /// When true, the client connects over TLS. - public bool UseTls { get; init; } - - /// Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning. - public string? ServerCertThumbprint { get; init; } - - /// Per-process shared secret the sidecar verifies in the Hello frame. - public string SharedSecret { get; init; } = ""; - /// Maximum number of queued rows the drain worker forwards in a single batch. public int BatchSize { get; init; } = 100; @@ -64,8 +51,6 @@ public sealed class AlarmHistorianOptions { var warnings = new List(); if (!Enabled) return warnings; - if (string.IsNullOrWhiteSpace(SharedSecret)) - warnings.Add("AlarmHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret."); if (!Path.IsPathRooted(DatabasePath)) warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path."); if (DrainIntervalSeconds <= 0) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index a5a74fa7..1038ea6d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions /// /// Registers shared runtime services. Currently binds /// to as the default; production deployments - /// override this with SqliteStoreAndForwardSink wrapping WonderwareHistorianClient. + /// override this with SqliteStoreAndForwardSink wrapping the HistorianGateway alarm writer. /// Call this BEFORE AddAkka. /// /// The service collection to register with. @@ -63,14 +63,14 @@ public static class ServiceCollectionExtensions /// Enabled=true, registers a (draining via the /// -supplied writer) as the , /// overriding the default. Otherwise a no-op (Null stays). - /// The writer is injected so the durable downstream (Wonderware named-pipe client) can be supplied - /// by the Host, which is the only project that references it. + /// The writer is injected so the durable downstream (the HistorianGateway alarm writer) can be + /// supplied by the Host, which is the only project that references it. /// /// The service collection to register with. /// The configuration carrying the AlarmHistorian section. /// /// Factory the Host supplies to build the concrete - /// (the Wonderware named-pipe client) from the bound options + the resolving provider. + /// (the HistorianGateway alarm writer) from the bound options + the resolving provider. /// /// The same instance for chaining. public static IServiceCollection AddAlarmHistorian( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs deleted file mode 100644 index d4132e04..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -// xUnit1051: MessagePackSerializer.Serialize/Deserialize have optional CancellationToken -// overloads; these are synchronous parity tests — suppressing the false-positive advisory. -#pragma warning disable xUnit1051 -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; - -/// -/// Wire-parity tests for the client-side IPC contracts (Contracts.cs + Framing.cs). -/// These tests pin the MessagePack byte representation of each DTO using known inputs -/// and assert byte-equality against expected values. Because the sidecar (.NET 4.8) -/// carries a byte-identical mirror of these DTOs, a silent [Key] index drift or -/// field-type change in either copy would cause a mismatch here and be caught at build -/// time — without needing to reference the net48 sidecar assembly from a net10 test -/// project (which the TFM mismatch prevents). (Finding 009.) -/// -public sealed class ContractsWireParityTests -{ - // ---- HistorianSampleDto ---- - // Fields at Key(0)=ValueBytes(null), Key(1)=Quality(0), Key(2)=TimestampUtcTicks(0) - // MessagePack fixarray(3) + nil + fixint(0) + fixint(0) = 93 c0 00 00 - - /// Verifies that HistorianSampleDto serialized bytes are stable. - [Fact] - public void HistorianSampleDto_SerializedBytes_AreStable() - { - var dto = new HistorianSampleDto { ValueBytes = null, Quality = 0, TimestampUtcTicks = 0 }; - var bytes = MessagePackSerializer.Serialize(dto); - - // fixarray(3) = 0x93, nil = 0xC0, fixint(0) = 0x00, fixint(0) = 0x00 - bytes.ShouldBe(new byte[] { 0x93, 0xC0, 0x00, 0x00 }); - } - - /// Verifies that HistorianSampleDto with value round-trips correctly. - [Fact] - public void HistorianSampleDto_WithValue_RoundTrips() - { - var original = new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(42.5), - Quality = 192, - TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks, - }; - var bytes = MessagePackSerializer.Serialize(original); - var roundTripped = MessagePackSerializer.Deserialize(bytes); - - roundTripped.Quality.ShouldBe((byte)192); - roundTripped.TimestampUtcTicks.ShouldBe(original.TimestampUtcTicks); - roundTripped.ValueBytes.ShouldBe(original.ValueBytes); - } - - // ---- HistorianAggregateSampleDto ---- - // Key(0)=Value(null), Key(1)=TimestampUtcTicks(0) - // fixarray(2) + nil + fixint(0) = 92 c0 00 - - /// Verifies that HistorianAggregateSampleDto serialized bytes are stable. - [Fact] - public void HistorianAggregateSampleDto_SerializedBytes_AreStable() - { - var dto = new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = 0 }; - var bytes = MessagePackSerializer.Serialize(dto); - - // fixarray(2) = 0x92, nil = 0xC0, fixint(0) = 0x00 - bytes.ShouldBe(new byte[] { 0x92, 0xC0, 0x00 }); - } - - // ---- ReadRawRequest ---- - // 5 fields at Key(0..4). TagName="", StartUtcTicks=0, EndUtcTicks=0, MaxValues=0, CorrelationId="" - // fixarray(5) + fixstr(0)="" + fixint(0) + fixint(0) + fixint(0) + fixstr(0)="" - - /// Verifies that an empty ReadRawRequest serializes as a fixed array of 5 elements. - [Fact] - public void ReadRawRequest_EmptyInstance_SerializesAsFixArray5() - { - var req = new ReadRawRequest(); - var bytes = MessagePackSerializer.Serialize(req); - - // Should start with fixarray(5) = 0x95 - bytes[0].ShouldBe((byte)0x95); - // Round-trip verification - var rt = MessagePackSerializer.Deserialize(bytes); - rt.TagName.ShouldBe(string.Empty); - rt.MaxValues.ShouldBe(0); - } - - /// Verifies that ReadRawRequest with values round-trips correctly. - [Fact] - public void ReadRawRequest_WithValues_RoundTrips() - { - var original = new ReadRawRequest - { - TagName = "Tank.Level", - StartUtcTicks = 100L, - EndUtcTicks = 200L, - MaxValues = 500, - CorrelationId = "abc", - }; - var bytes = MessagePackSerializer.Serialize(original); - var rt = MessagePackSerializer.Deserialize(bytes); - - rt.TagName.ShouldBe("Tank.Level"); - rt.StartUtcTicks.ShouldBe(100L); - rt.EndUtcTicks.ShouldBe(200L); - rt.MaxValues.ShouldBe(500); - rt.CorrelationId.ShouldBe("abc"); - } - - // ---- ReadRawReply ---- - - /// Verifies that ReadRawReply round-trips correctly. - [Fact] - public void ReadRawReply_RoundTrips() - { - var original = new ReadRawReply - { - CorrelationId = "x", - Success = true, - Error = null, - Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = 99L }], - }; - var bytes = MessagePackSerializer.Serialize(original); - var rt = MessagePackSerializer.Deserialize(bytes); - - rt.CorrelationId.ShouldBe("x"); - rt.Success.ShouldBeTrue(); - rt.Error.ShouldBeNull(); - rt.Samples.Length.ShouldBe(1); - rt.Samples[0].Quality.ShouldBe((byte)192); - rt.Samples[0].TimestampUtcTicks.ShouldBe(99L); - } - - // ---- ReadAtTimeRequest / ReadAtTimeReply ---- - - /// Verifies that ReadAtTimeRequest round-trips correctly. - [Fact] - public void ReadAtTimeRequest_RoundTrips() - { - var ticks = new long[] { 100L, 200L, 300L }; - var original = new ReadAtTimeRequest { TagName = "T", TimestampsUtcTicks = ticks, CorrelationId = "c" }; - var bytes = MessagePackSerializer.Serialize(original); - var rt = MessagePackSerializer.Deserialize(bytes); - - rt.TagName.ShouldBe("T"); - rt.TimestampsUtcTicks.ShouldBe(ticks); - rt.CorrelationId.ShouldBe("c"); - } - - // ---- WriteAlarmEventsRequest / WriteAlarmEventsReply ---- - - /// Verifies that WriteAlarmEventsRequest round-trips correctly. - [Fact] - public void WriteAlarmEventsRequest_RoundTrips() - { - var original = new WriteAlarmEventsRequest - { - Events = - [ - new AlarmHistorianEventDto - { - EventId = "ev1", - SourceName = "Tank/HiHi", - ConditionId = "HiHi", - AlarmType = "LimitAlarm:Activated", - Message = "msg", - Severity = 700, - EventTimeUtcTicks = 999L, - AckComment = null, - }, - ], - CorrelationId = "r", - }; - var bytes = MessagePackSerializer.Serialize(original); - var rt = MessagePackSerializer.Deserialize(bytes); - - rt.CorrelationId.ShouldBe("r"); - rt.Events.Length.ShouldBe(1); - rt.Events[0].EventId.ShouldBe("ev1"); - rt.Events[0].SourceName.ShouldBe("Tank/HiHi"); - rt.Events[0].Severity.ShouldBe((ushort)700); - rt.Events[0].EventTimeUtcTicks.ShouldBe(999L); - } - - /// Verifies that WriteAlarmEventsReply round-trips correctly (legacy PerEventOk path). - [Fact] - public void WriteAlarmEventsReply_RoundTrips() - { - var original = new WriteAlarmEventsReply - { - CorrelationId = "r", - Success = true, - Error = null, - PerEventOk = [true, false, true], - }; - var bytes = MessagePackSerializer.Serialize(original); - var rt = MessagePackSerializer.Deserialize(bytes); - - rt.CorrelationId.ShouldBe("r"); - rt.Success.ShouldBeTrue(); - rt.PerEventOk.ShouldBe(new[] { true, false, true }); - } - - /// - /// Pins the [Key(4)] index for , - /// the additive granular status field added in the feddc2b8 commit. A silent - /// Key-index drift in either the client or the sidecar mirror copy would swap the legacy - /// PerEventOk bool array and the new status byte array, misclassifying outcomes - /// at runtime. (Finding 013.) - /// - [Fact] - public void WriteAlarmEventsReply_PerEventStatus_IsAtKey4_AndRoundTrips() - { - var original = new WriteAlarmEventsReply - { - CorrelationId = "s", - Success = true, - PerEventOk = [true], - PerEventStatus = [0, 1, 2], // Ack, Retry, Permanent - }; - var bytes = MessagePackSerializer.Serialize(original); - - // The array must start with fixarray(5) — five keys at indices 0-4. - bytes[0].ShouldBe((byte)0x95, "WriteAlarmEventsReply must be a 5-field MessagePack array"); - - var rt = MessagePackSerializer.Deserialize(bytes); - rt.CorrelationId.ShouldBe("s"); - rt.Success.ShouldBeTrue(); - rt.PerEventOk.ShouldBe(new[] { true }); - // Key(4): PerEventStatus must round-trip independently of Key(3): PerEventOk. - rt.PerEventStatus.ShouldBe(new byte[] { 0, 1, 2 }); - } - - // ---- MessageKind enum values are pinned ---- - // Changing a MessageKind value is a wire break; pin them explicitly. - - /// Verifies that MessageKind enum values are stable. - [Fact] - public void MessageKind_Values_AreStable() - { - ((byte)MessageKind.Hello).ShouldBe((byte)0x01); - ((byte)MessageKind.HelloAck).ShouldBe((byte)0x02); - ((byte)MessageKind.ReadRawRequest).ShouldBe((byte)0x10); - ((byte)MessageKind.ReadRawReply).ShouldBe((byte)0x11); - ((byte)MessageKind.ReadProcessedRequest).ShouldBe((byte)0x12); - ((byte)MessageKind.ReadProcessedReply).ShouldBe((byte)0x13); - ((byte)MessageKind.ReadAtTimeRequest).ShouldBe((byte)0x14); - ((byte)MessageKind.ReadAtTimeReply).ShouldBe((byte)0x15); - ((byte)MessageKind.ReadEventsRequest).ShouldBe((byte)0x16); - ((byte)MessageKind.ReadEventsReply).ShouldBe((byte)0x17); - ((byte)MessageKind.WriteAlarmEventsRequest).ShouldBe((byte)0x20); - ((byte)MessageKind.WriteAlarmEventsReply).ShouldBe((byte)0x21); - } - - // ---- Framing constants are pinned ---- - - /// Verifies that framing constants are stable. - [Fact] - public void Framing_Constants_AreStable() - { - Framing.LengthPrefixSize.ShouldBe(4); - Framing.KindByteSize.ShouldBe(1); - Framing.MaxFrameBodyBytes.ShouldBe(16 * 1024 * 1024); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/FakeSidecarServer.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/FakeSidecarServer.cs deleted file mode 100644 index aef45461..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/FakeSidecarServer.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; - -/// -/// In-process fake of the Wonderware historian sidecar. Reuses the client-side framing -/// code (which is byte-identical to the real sidecar) so the wire bytes round-trip -/// correctly without requiring the .NET 4.8 sidecar binary at test time. Listens on a -/// loopback and serves one connection at a time, mirroring the -/// real sidecar's TcpFrameServer single-active-connection model. -/// -internal sealed class FakeSidecarServer : IAsyncDisposable -{ - private readonly string _expectedSecret; - private readonly TcpListener _listener; - private readonly CancellationTokenSource _cts = new(); - private Task? _loop; - - /// Gets or sets the handler for ReadRaw requests. - public Func OnReadRaw { get; set; } = _ => new ReadRawReply { Success = true }; - - /// Gets or sets the handler for ReadProcessed requests. - public Func OnReadProcessed { get; set; } = _ => new ReadProcessedReply { Success = true }; - - /// Gets or sets the handler for ReadAtTime requests. - public Func OnReadAtTime { get; set; } = _ => new ReadAtTimeReply { Success = true }; - - /// Gets or sets the handler for ReadEvents requests. - public Func OnReadEvents { get; set; } = _ => new ReadEventsReply { Success = true }; - - /// Gets or sets the handler for WriteAlarmEvents requests. - public Func OnWriteAlarmEvents { get; set; } = req - => new WriteAlarmEventsReply { Success = true, PerEventOk = Enumerable.Repeat(true, req.Events.Length).ToArray() }; - - /// Force-disconnect the next accepted client mid-call to exercise reconnect. - public bool DisconnectAfterHandshake { get; set; } - - /// - /// Drop the connection after the handshake but before replying to any non-Hello request. - /// Armed for every connection until reset. Used to exercise the WriteBatchAsync catch - /// path and the second-attempt-also-fails propagation path. - /// - public bool DisconnectBeforeReply { get; set; } - - /// - /// Reply to the first non-Hello request with this kind instead of the expected kind, - /// to exercise detection in ExchangeAsync. - /// Reset to null after the first mis-routed reply. - /// - public MessageKind? ReplyWithWrongKind { get; set; } - - /// - /// Stall indefinitely after receiving a request before sending any reply, so the client's - /// call-timeout token fires. Used to test the CallTimeout path. - /// - public bool StallAfterRequest { get; set; } - - /// Initializes a new instance of FakeSidecarServer with the specified expected secret. - /// The expected shared secret for handshake validation. - public FakeSidecarServer(string expectedSecret) - { - _expectedSecret = expectedSecret; - // Bind synchronously in the ctor so BoundPort is readable before StartAsync returns. - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - } - - /// Gets the loopback host the listener is bound to. - public string Host => "127.0.0.1"; - - /// Gets the TCP port the listener actually bound (OS-assigned). - public int BoundPort => ((IPEndPoint)_listener.LocalEndpoint).Port; - - /// Starts the fake sidecar server asynchronously. The listener is already bound (ctor). - public Task StartAsync() - { - _loop = Task.Run(() => RunAsync(_cts.Token)); - return Task.CompletedTask; - } - - private async Task RunAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - TcpClient tcpClient; - try { tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } - catch (OperationCanceledException) { break; } - catch (ObjectDisposedException) { break; } - - using (tcpClient) - { - try - { - tcpClient.NoDelay = true; - var stream = tcpClient.GetStream(); - using var reader = new FrameReader(stream, leaveOpen: true); - using var writer = new FrameWriter(stream, leaveOpen: true); - - // Hello handshake. - var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false); - if (first is null || first.Value.Kind != MessageKind.Hello) continue; - var hello = MessagePackSerializer.Deserialize(first.Value.Body); - - if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal)) - { - await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct); - continue; - } - - await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct); - - if (DisconnectAfterHandshake) - { - DisconnectAfterHandshake = false; // arm once - tcpClient.Close(); - continue; - } - - while (!ct.IsCancellationRequested) - { - var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false); - if (frame is null) break; - - // Drop before sending any reply — lets the client fall into its catch / - // retry path or propagate on second failure. - if (DisconnectBeforeReply) - { - tcpClient.Close(); - break; - } - - // Stall indefinitely to let the client's call-timeout token fire. - if (StallAfterRequest) - { - await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); - break; - } - - // Optionally send a deliberately wrong kind back to exercise - // InvalidDataException detection in the client's ExchangeAsync. - if (ReplyWithWrongKind.HasValue) - { - var wrongKind = ReplyWithWrongKind.Value; - ReplyWithWrongKind = null; // arm once - // Send an empty body with the wrong kind so the client can parse it. - await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct); - continue; - } - - switch (frame.Value.Kind) - { - case MessageKind.ReadRawRequest: - { - var req = MessagePackSerializer.Deserialize(frame.Value.Body); - var reply = OnReadRaw(req); - reply.CorrelationId = req.CorrelationId; - await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct); - break; - } - case MessageKind.ReadProcessedRequest: - { - var req = MessagePackSerializer.Deserialize(frame.Value.Body); - var reply = OnReadProcessed(req); - reply.CorrelationId = req.CorrelationId; - await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct); - break; - } - case MessageKind.ReadAtTimeRequest: - { - var req = MessagePackSerializer.Deserialize(frame.Value.Body); - var reply = OnReadAtTime(req); - reply.CorrelationId = req.CorrelationId; - await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct); - break; - } - case MessageKind.ReadEventsRequest: - { - var req = MessagePackSerializer.Deserialize(frame.Value.Body); - var reply = OnReadEvents(req); - reply.CorrelationId = req.CorrelationId; - await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct); - break; - } - case MessageKind.WriteAlarmEventsRequest: - { - var req = MessagePackSerializer.Deserialize(frame.Value.Body); - var reply = OnWriteAlarmEvents(req); - reply.CorrelationId = req.CorrelationId; - await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct); - break; - } - } - } - } - catch (OperationCanceledException) { break; } - catch (IOException) { /* peer dropped — accept next */ } - } - } - } - - /// Releases all resources used by the fake sidecar server. - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - try { _listener.Stop(); } catch { /* ignore */ } - if (_loop is not null) - { - try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ } - } - _cts.Dispose(); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs deleted file mode 100644 index e9ba17ef..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; - -/// -/// Tests for . Each scenario binds a -/// loopback on 127.0.0.1:0, accepts on a background task, -/// and drives the client factory against it — proving a plaintext stream round-trips a byte, -/// a TLS connection succeeds when the pinned thumbprint matches, and fails when it does not. -/// -public sealed class TcpConnectFactoryTests -{ - // Generous timeout so the deterministic tests never hang CI if a side stalls. - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - - /// Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key. - private static X509Certificate2 MakeSelfSignedCert() - { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false)); - using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); - // Round-trip through a PFX so the returned cert carries an exportable private key. - var pfx = ephemeral.Export(X509ContentType.Pfx, "pw"); - return X509CertificateLoader.LoadPkcs12(pfx, "pw", X509KeyStorageFlags.Exportable); - } - - /// Plaintext: the factory returns a connected stream; a byte written server-side reads back client-side. - [Fact] - public async Task Plaintext_ReturnsConnectedStream_ByteRoundTrips() - { - using var cts = new CancellationTokenSource(Timeout); - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; - - // Accept one client and push a single byte from the server side. - var serverTask = Task.Run(async () => - { - using var server = await listener.AcceptTcpClientAsync(cts.Token); - var serverStream = server.GetStream(); - await serverStream.WriteAsync(new byte[] { 0x7A }, cts.Token); - await serverStream.FlushAsync(cts.Token); - // Hold the connection open until the client has read. - await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); - }, cts.Token); - - var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") - { - UseTls = false, - }; - - await using var clientStream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token); - var buffer = new byte[1]; - var read = await clientStream.ReadAsync(buffer, cts.Token); - - read.ShouldBe(1); - buffer[0].ShouldBe((byte)0x7A); - - await serverTask; - listener.Stop(); - } - - /// TLS pin match: a self-signed cert pinned by thumbprint authenticates successfully. - [Fact] - public async Task Tls_PinnedThumbprintMatches_ConnectsSuccessfully() - { - using var cts = new CancellationTokenSource(Timeout); - using var cert = MakeSelfSignedCert(); - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; - - var serverTask = Task.Run(async () => - { - using var server = await listener.AcceptTcpClientAsync(cts.Token); - var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false); - await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false, - enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); - // Hold open until the client finished its handshake. - await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); - ssl.Dispose(); - }, cts.Token); - - var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") - { - UseTls = true, - ServerCertThumbprint = cert.GetCertHashString(), - }; - - await using var stream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token); - stream.ShouldBeOfType(); - ((SslStream)stream).IsAuthenticated.ShouldBeTrue(); - - await serverTask; - listener.Stop(); - } - - /// TLS wrong thumbprint: the pin check fails the validation callback → AuthenticationException. - [Fact] - public async Task Tls_WrongThumbprint_ThrowsAuthenticationException() - { - using var cts = new CancellationTokenSource(Timeout); - using var cert = MakeSelfSignedCert(); - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; - - // The server still attempts its handshake; it will fault when the client aborts. Swallow. - var serverTask = Task.Run(async () => - { - try - { - using var server = await listener.AcceptTcpClientAsync(cts.Token); - var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false); - await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false, - enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); - ssl.Dispose(); - } - catch - { - // Expected — the client rejects the cert and tears the connection down. - } - }, cts.Token); - - var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") - { - UseTls = true, - ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus - }; - - await Should.ThrowAsync( - async () => await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token)); - - try { await serverTask; } catch { /* ignore */ } - listener.Stop(); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs deleted file mode 100644 index 8e8b8fb4..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; - -/// -/// Unit tests for TCP/TLS fields. -/// -public sealed class WonderwareHistorianClientOptionsTests -{ - [Fact] - public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet() - { - var opts = new WonderwareHistorianClientOptions("h", 32569, "secret") - { - UseTls = true, - ServerCertThumbprint = "AB" - }; - - opts.Host.ShouldBe("h"); - opts.Port.ShouldBe(32569); - opts.UseTls.ShouldBeTrue(); - opts.ServerCertThumbprint.ShouldBe("AB"); - } - - [Fact] - public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet() - { - var opts = new WonderwareHistorianClientOptions("host", 32569, "secret"); - - opts.Host.ShouldBe("host"); - opts.Port.ShouldBe(32569); - opts.UseTls.ShouldBeFalse(); - opts.ServerCertThumbprint.ShouldBeNull(); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs deleted file mode 100644 index 503d6b41..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs +++ /dev/null @@ -1,890 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; - -/// -/// End-to-end tests for : every interface method -/// round-trips over a real loopback TCP connection against the in-process -/// , which reuses the client's own byte-identical framing -/// code. Covers byte→uint quality mapping, BadNoData propagation for null aggregate -/// buckets, alarm-write per-event status flow, Hello handshake rejection on bad secret, -/// and reconnect after a transport drop. -/// -public sealed class WonderwareHistorianClientTests -{ - private const string Secret = "test-secret-123"; - - private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new( - Host: "127.0.0.1", - Port: server.BoundPort, - SharedSecret: Secret, - PeerName: "test", - ConnectTimeout: TimeSpan.FromSeconds(2), - CallTimeout: TimeSpan.FromSeconds(2)) - { - UseTls = false, - }; - - /// - /// Creates a client over loopback TCP against the fake's bound port using the public ctor - /// (which dials TCP). - /// - private static WonderwareHistorianClient TcpClientFor(FakeSidecarServer server) - => new(OptsFor(server)); - - /// Verifies that ReadRawAsync round-trips samples and maps quality bytes to OPC UA status codes. - [Fact] - public async Task ReadRawAsync_RoundTripsSamples_AndMapsQualityByteToOpcUaStatusCode() - { - await using var server = new FakeSidecarServer(Secret) - { - OnReadRaw = req => new ReadRawReply - { - Success = true, - Samples = - [ - new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(42.0), - Quality = 192, // Good - TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks, - }, - new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(43.5), - Quality = 8, // Bad_NotConnected - TimestampUtcTicks = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc).Ticks, - }, - ], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadRawAsync("Tank.Level", - new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), - 100, CancellationToken.None); - - result.ContinuationPoint.ShouldBeNull(); - result.Samples.Count.ShouldBe(2); - result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc)); - result.Samples[1].StatusCode.ShouldBe(0x808A0000u); // Bad_NotConnected - } - - /// Verifies that ReadProcessedAsync maps null buckets to BadNoData status. - [Fact] - public async Task ReadProcessedAsync_NullBuckets_MapToBadNoData() - { - await using var server = new FakeSidecarServer(Secret) - { - OnReadProcessed = _ => new ReadProcessedReply - { - Success = true, - Buckets = - [ - new HistorianAggregateSampleDto { Value = 50.0, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks }, - new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc).Ticks }, - ], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadProcessedAsync("Tank.Level", - new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc), - TimeSpan.FromMinutes(1), HistoryAggregateType.Average, CancellationToken.None); - - result.Samples.Count.ShouldBe(2); - result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[0].Value.ShouldBe(50.0); - result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData - result.Samples[1].Value.ShouldBeNull(); - } - - /// Verifies that ReadAtTimeAsync preserves timestamp order. - [Fact] - public async Task ReadAtTimeAsync_PreservesTimestampOrder() - { - var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc); - var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc); - - await using var server = new FakeSidecarServer(Secret) - { - OnReadAtTime = req => new ReadAtTimeReply - { - Success = true, - Samples = req.TimestampsUtcTicks - .Select(ticks => new HistorianSampleDto { Quality = 192, TimestampUtcTicks = ticks }) - .ToArray(), - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2 }, CancellationToken.None); - - result.Samples.Count.ShouldBe(2); - result.Samples[0].SourceTimestampUtc.ShouldBe(t1); - result.Samples[1].SourceTimestampUtc.ShouldBe(t2); - } - - /// Verifies that ReadAtTimeAsync aligns by timestamp and fills gaps with bad status. - [Fact] - public async Task ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad() - { - var t1 = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc); - var t2 = new DateTime(2026, 4, 29, 2, 0, 0, DateTimeKind.Utc); - var t3 = new DateTime(2026, 4, 29, 3, 0, 0, DateTimeKind.Utc); - - await using var server = new FakeSidecarServer(Secret) - { - // Sidecar returns only t3 and t1 (out of order), drops t2 entirely. A - // contract-compliant client must realign by timestamp and synthesize a - // Bad-quality snapshot for the missing t2. - OnReadAtTime = _ => new ReadAtTimeReply - { - Success = true, - Samples = - [ - new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(3.0), - Quality = 192, TimestampUtcTicks = t3.Ticks, - }, - new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(1.0), - Quality = 192, TimestampUtcTicks = t1.Ticks, - }, - ], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadAtTimeAsync("Tank.Level", new[] { t1, t2, t3 }, CancellationToken.None); - - // Result MUST be the same length and order as the request. - result.Samples.Count.ShouldBe(3); - - result.Samples[0].SourceTimestampUtc.ShouldBe(t1); - result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[0].Value.ShouldBe(1.0); - - // t2 was not returned by the sidecar → Bad-quality gap snapshot at the requested time. - result.Samples[1].SourceTimestampUtc.ShouldBe(t2); - result.Samples[1].StatusCode.ShouldBe(0x80000000u); // Bad - result.Samples[1].Value.ShouldBeNull(); - - result.Samples[2].SourceTimestampUtc.ShouldBe(t3); - result.Samples[2].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[2].Value.ShouldBe(3.0); - } - - /// Verifies that ReadEventsAsync preserves event field values. - [Fact] - public async Task ReadEventsAsync_PreservesEventFields() - { - var eid = Guid.NewGuid().ToString("N"); - await using var server = new FakeSidecarServer(Secret) - { - OnReadEvents = _ => new ReadEventsReply - { - Success = true, - Events = - [ - new HistorianEventDto - { - EventId = eid, Source = "Tank.HiHi", - EventTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc).Ticks, - ReceivedTimeUtcTicks = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc).Ticks, - DisplayText = "Level high-high", Severity = 800, - }, - ], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadEventsAsync("Tank.HiHi", - new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), - 100, CancellationToken.None); - - result.Events.Count.ShouldBe(1); - result.Events[0].EventId.ShouldBe(eid); - result.Events[0].SourceName.ShouldBe("Tank.HiHi"); - result.Events[0].Message.ShouldBe("Level high-high"); - result.Events[0].Severity.ShouldBe((ushort)800); - } - - /// Verifies that ReadRawAsync throws InvalidOperationException on server errors. - [Fact] - public async Task ReadRawAsync_ServerError_ThrowsInvalidOperation() - { - await using var server = new FakeSidecarServer(Secret) - { - OnReadRaw = _ => new ReadRawReply { Success = false, Error = "historian unreachable" }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - var ex = await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); - ex.Message.ShouldContain("historian unreachable"); - } - - /// Verifies that WriteBatchAsync maps per-event results to acknowledge or retry outcomes. - [Fact] - public async Task WriteBatchAsync_PerEventOk_MapsToAckOrRetryPlease() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = req => new WriteAlarmEventsReply - { - Success = true, - PerEventOk = req.Events.Select(e => e.EventId != "ev-fail").ToArray(), - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "operator", null, DateTime.UtcNow), - new AlarmHistorianEvent("ev-fail", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "operator", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(2); - outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); - outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); - } - - /// Verifies that WriteBatchAsync returns retry outcomes for whole call failures. - [Fact] - public async Task WriteBatchAsync_WholeCallFailure_ReturnsRetryPleaseForEveryEvent() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = false, - Error = "historian event-store down", - PerEventOk = new bool[2], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(2); - outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); - outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); - } - - /// - /// The granular PerEventStatus wire field maps directly: 0→Ack, 1→Retry, 2→PermanentFail. - /// A poison event the sidecar marks Permanent (status 2) must dead-letter via - /// rather than retrying. - /// - [Fact] - public async Task WriteBatchAsync_PerEventStatusPermanent_MapsToPermanentFail() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = true, - PerEventStatus = [2], // Permanent - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-poison", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(1); - outcomes[0].ShouldBe(HistorianWriteOutcome.PermanentFail); - } - - /// - /// PerEventStatus = 0 maps to ; the granular path - /// takes precedence over the legacy PerEventOk bool when both are present. - /// - [Fact] - public async Task WriteBatchAsync_PerEventStatusAck_MapsToAck() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = true, - PerEventStatus = [0], // Ack - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-ok", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(1); - outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); - } - - /// - /// When PerEventStatus is present but its length does not equal the batch size, - /// the client must ignore it and fall back to the legacy PerEventOk path to - /// avoid mis-indexing into the status array. Here a 2-event batch receives - /// PerEventStatus=[1] (length 1) but PerEventOk=[true, false]; the - /// outcomes must reflect the PerEventOk values ([Ack, RetryPlease]), not the status - /// byte (which would have produced [RetryPlease] had it been used). - /// - [Fact] - public async Task WriteBatchAsync_PerEventStatusLengthMismatch_FallsBackToPerEventOk() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = true, - PerEventStatus = [1], // length 1 ≠ batch count 2 → must be ignored - PerEventOk = [true, false], // legacy fallback: true→Ack, false→RetryPlease - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Acknowledged", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(2); - outcomes[0].ShouldBe(HistorianWriteOutcome.Ack); // PerEventOk[0] = true - outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); // PerEventOk[1] = false - } - - /// - /// Status byte 1 (the only value that is neither 0 nor 2) must map to - /// via the default arm of the - /// PerEventStatus switch. A single-event batch with PerEventStatus=[1] - /// (length matches batch) must yield [RetryPlease]. - /// - [Fact] - public async Task WriteBatchAsync_PerEventStatusRetry_MapsToRetryPlease() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = true, - PerEventStatus = [1], // status 1 → RetryPlease - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-retry", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(1); - outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); - } - - /// - /// Rolling-deploy back-compat: an older sidecar that sends an empty PerEventStatus but a - /// populated PerEventOk must still classify via the legacy bool path (false→RetryPlease). - /// - [Fact] - public async Task WriteBatchAsync_EmptyPerEventStatus_FallsBackToLegacyPerEventOk() - { - await using var server = new FakeSidecarServer(Secret) - { - OnWriteAlarmEvents = _ => new WriteAlarmEventsReply - { - Success = true, - PerEventStatus = [], // older sidecar — no granular status - PerEventOk = [false], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-legacy", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - }; - - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(1); - outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); - } - - /// Verifies that Hello handshake throws UnauthorizedAccessException on secret mismatch. - [Fact] - public async Task Hello_BadSecret_ThrowsUnauthorizedAccess() - { - await using var server = new FakeSidecarServer("different-secret"); - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - var ex = await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); - ex.Message.ShouldContain("shared-secret-mismatch"); - } - - /// Verifies that the client retries after a transport drop. - [Fact] - public async Task Reconnect_AfterTransportDrop_RetriesOnce() - { - await using var server = new FakeSidecarServer(Secret) - { - // First connection drops after handshake → client retries on next call. - DisconnectAfterHandshake = true, - OnReadRaw = req => new ReadRawReply - { - Success = true, - Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = req.StartUtcTicks }], - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - // First call: handshake + dropped. Reconnect kicks in inside the channel; second - // attempt within the same InvokeAsync succeeds. From the caller's perspective it's - // one ReadRawAsync that returns a sample. - var result = await client.ReadRawAsync("Tag", - new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), - 100, CancellationToken.None); - - result.Samples.Count.ShouldBe(1); - } - - /// Verifies that GetHealthSnapshot tracks success and failure counts. - [Fact] - public async Task GetHealthSnapshot_TracksSuccessAndFailureCounts() - { - var failNext = false; - await using var server = new FakeSidecarServer(Secret) - { - OnReadRaw = _ => failNext - ? new ReadRawReply { Success = false, Error = "boom" } - : new ReadRawReply { Success = true }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None); - - failNext = true; - await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None)); - - var snap = client.GetHealthSnapshot(); - snap.TotalQueries.ShouldBe(2); - snap.TotalSuccesses.ShouldBe(1); - snap.TotalFailures.ShouldBe(1); - snap.ConsecutiveFailures.ShouldBe(1); - snap.LastError.ShouldNotBeNull(); - snap.ProcessConnectionOpen.ShouldBeTrue(); - } - - // ===== Finding-009: missing edge-case tests ===== - - /// - /// (2) A transport drop during a write (the catch path in WriteBatchAsync) must return - /// RetryPlease for every event in the batch — never throw, never PermanentFail. - /// - [Fact] - public async Task WriteBatchAsync_TransportDropDuringWrite_ReturnsRetryPleaseForEveryEvent() - { - // Server disconnects before replying to the write request. The client's single retry - // reconnects; on the second attempt the server is still armed to disconnect, so both - // attempts fail and the catch block fires. - await using var server = new FakeSidecarServer(Secret) - { - DisconnectBeforeReply = true, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var batch = new[] - { - new AlarmHistorianEvent("ev-1", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow), - new AlarmHistorianEvent("ev-2", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Cleared", "msg", "u", null, DateTime.UtcNow), - }; - - // WriteBatchAsync must not throw — it absorbs transport failures as RetryPlease. - var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Count.ShouldBe(2); - outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease); - outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease); - } - - /// - /// (3) When both the first attempt and the single retry fail (the "second attempt also - /// fails" path in InvokeAsync), the exception propagates to the caller. - /// - [Fact] - public async Task InvokeAsync_BothAttemptsFailTransport_PropagatesException() - { - // DisconnectBeforeReply stays true so both the first attempt and the single retry - // inside InvokeAsync are dropped, causing the second ExchangeAsync to throw. - await using var server = new FakeSidecarServer(Secret) - { - DisconnectBeforeReply = true, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - // ReadRawAsync uses Invoke, which propagates the exception when both attempts fail. - await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); - } - - /// - /// (4) A stalled sidecar that never sends a reply must cause an - /// within the configured CallTimeout. - /// - [Fact] - public async Task ReadRawAsync_StalledSidecar_TimesOutWithOperationCanceledException() - { - await using var server = new FakeSidecarServer(Secret) - { - StallAfterRequest = true, - }; - await server.StartAsync(); - - var opts = new WonderwareHistorianClientOptions( - Host: "127.0.0.1", - Port: server.BoundPort, - SharedSecret: Secret, - PeerName: "test", - ConnectTimeout: TimeSpan.FromSeconds(2), - CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed - { - UseTls = false, - }; - - await using var client = new WonderwareHistorianClient(opts); - - // The stall means neither the first nor the retry can complete, so the timeout - // linked-token should cancel the operation. - await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); - } - - /// - /// (5) is derived client-side as the - /// time-weighted Average multiplied by the interval duration in seconds, because the - /// Wonderware AnalogSummary query exposes no Total column. The client must issue the - /// wire request with the Average column and scale every returned bucket value by - /// interval.TotalSeconds, carrying the bucket's quality and timestamp through. - /// - [Fact] - public async Task ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds() - { - var bucketTs = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc); - string? requestedColumn = null; - - await using var server = new FakeSidecarServer(Secret) - { - OnReadProcessed = req => - { - // Capture the column the client asked for: Total must be requested as Average. - requestedColumn = req.AggregateColumn; - return new ReadProcessedReply - { - Success = true, - Buckets = - [ - // One Good Average bucket of 2.0; with a 60s interval the derived - // Total is 2.0 * 60 = 120.0. - new HistorianAggregateSampleDto { Value = 2.0, TimestampUtcTicks = bucketTs.Ticks }, - // A null (unavailable) Average bucket must stay BadNoData / null. - new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = bucketTs.AddMinutes(1).Ticks }, - ], - }; - }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - var result = await client.ReadProcessedAsync("Tank.Level", - new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc), - TimeSpan.FromMinutes(1), HistoryAggregateType.Total, CancellationToken.None); - - // The wire request asks for the Average column — Total has no AnalogSummary column. - requestedColumn.ShouldBe("Average"); - - result.Samples.Count.ShouldBe(2); - - // Total = Average (2.0) x interval-seconds (60) = 120.0, quality + timestamp carried. - result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[0].Value.ShouldBe(120.0); - result.Samples[0].SourceTimestampUtc.ShouldBe(bucketTs); - - // Null Average bucket → still BadNoData / null after scaling. - result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData - result.Samples[1].Value.ShouldBeNull(); - result.Samples[1].SourceTimestampUtc.ShouldBe(bucketTs.AddMinutes(1)); - } - - /// - /// (6) When the sidecar replies with a the client does not - /// expect (e.g. ReadRawReply where ReadAtTimeReply was expected), the client must throw - /// . - /// - [Fact] - public async Task ReadRawAsync_SidecarRepliesWithWrongKind_ThrowsInvalidDataException() - { - await using var server = new FakeSidecarServer(Secret) - { - // Force the server to reply with ReadAtTimeReply instead of ReadRawReply. - ReplyWithWrongKind = MessageKind.ReadAtTimeReply, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None)); - } - - // ===== Finding-003 / Finding-004: health counter consistency ===== - - /// - /// (Finding 003 + 004) A sidecar-level failure must be classified once: TotalSuccesses - /// must stay at 0, TotalFailures must become 1, and TotalQueries / TotalSuccesses / - /// TotalFailures must all be updated under the same lock so a concurrent snapshot can - /// never observe inflated successes or out-of-band TotalQueries. This pins behaviour so - /// a future regression to the "RecordSuccess then undo via ReclassifySuccessAsFailure" - /// dance is caught. - /// - [Fact] - public async Task GetHealthSnapshot_SidecarFailure_NeverInflatesSuccessCounter() - { - await using var server = new FakeSidecarServer(Secret) - { - OnReadRaw = _ => new ReadRawReply { Success = false, Error = "boom" }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - await Should.ThrowAsync(() => - client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None)); - - var snap = client.GetHealthSnapshot(); - snap.TotalQueries.ShouldBe(1); - snap.TotalSuccesses.ShouldBe(0); - snap.TotalFailures.ShouldBe(1); - snap.ConsecutiveFailures.ShouldBe(1); - snap.LastError.ShouldNotBeNull(); - } - - /// - /// (Finding 003) Concurrent calls + concurrent - /// reads must observe consistent counters. Specifically, TotalSuccesses + TotalFailures - /// must equal TotalQueries at every observed snapshot (no torn read between an - /// Interlocked-incremented TotalQueries and a lock-protected outcome counter). The - /// channel serializes calls, so the test is observable: each completed query strictly - /// increments either successes or failures by one. - /// - [Fact] - public async Task GetHealthSnapshot_ConcurrentCallsAndReads_CountersAreInternallyConsistent() - { - await using var server = new FakeSidecarServer(Secret) - { - OnReadRaw = _ => new ReadRawReply { Success = true }, - }; - await server.StartAsync(); - - await using var client = TcpClientFor(server); - - using var stop = new CancellationTokenSource(); - var readerSawInconsistent = false; - -#pragma warning disable xUnit1051 // Internal Task.Run loop drives a polling stress test; cancellation flows via stop.IsCancellationRequested below. - var reader = Task.Run(() => - { - while (!stop.IsCancellationRequested) - { - var snap = client.GetHealthSnapshot(); - // Every completed call increments TotalQueries AND exactly one of - // TotalSuccesses or TotalFailures under the same lock; an in-flight call - // has not yet incremented any of them. So TotalQueries should always equal - // the sum of TotalSuccesses + TotalFailures (no in-between state visible). - if (snap.TotalSuccesses + snap.TotalFailures != snap.TotalQueries) - { - readerSawInconsistent = true; - } - } - }); -#pragma warning restore xUnit1051 - - for (var i = 0; i < 50; i++) - { - await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, TestContext.Current.CancellationToken); - } - - stop.Cancel(); - await reader; - - readerSawInconsistent.ShouldBeFalse( - "GetHealthSnapshot exposed TotalQueries that disagreed with the sum of TotalSuccesses + TotalFailures — counters are not updated under a single lock."); - - var final = client.GetHealthSnapshot(); - final.TotalQueries.ShouldBe(50); - final.TotalSuccesses.ShouldBe(50); - final.TotalFailures.ShouldBe(0); - } - - // ===== Task 3: default public ctor dials TCP ===== - - /// - /// Verifies that the default public ctor connects over TCP rather than named-pipe by - /// constructing the client against a loopback and asserting - /// that a ReadRaw round-trip returns the known sample. If the ctor still dialled a - /// named pipe the connect would fail because no pipe is listening. - /// - [Fact] - public async Task DefaultCtor_DialsTcp_ReadRawRoundTrips() - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(10)); - - // 1. Start a loopback TCP listener on an OS-assigned port. - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; - - var expectedTicks = new DateTime(2026, 6, 12, 8, 0, 0, DateTimeKind.Utc).Ticks; - var expectedValue = MessagePackSerializer.Serialize(99.0, cancellationToken: TestContext.Current.CancellationToken); - - // 2. Accept one client in the background and drive the server side of the protocol. - // Intentional: the background server task uses cts.Token (a linked+timeout source) - // rather than TestContext.Current.CancellationToken directly, because it adds a - // wall-clock safety bound so the test never hangs CI. -#pragma warning disable xUnit1051 - var serverTask = Task.Run(async () => - { - using var server = await listener.AcceptTcpClientAsync(cts.Token); - server.NoDelay = true; - var stream = server.GetStream(); - using var reader = new FrameReader(stream, leaveOpen: true); - using var writer = new FrameWriter(stream, leaveOpen: true); - - // Hello handshake. - var helloFrame = await reader.ReadFrameAsync(cts.Token); - helloFrame.ShouldNotBeNull(); - helloFrame!.Value.Kind.ShouldBe(MessageKind.Hello); - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = true, HostName = "test-tcp-sidecar" }, cts.Token); - - // ReadRaw request. - var reqFrame = await reader.ReadFrameAsync(cts.Token); - reqFrame.ShouldNotBeNull(); - reqFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawRequest); - var req = MessagePackSerializer.Deserialize(reqFrame.Value.Body); - - var reply = new ReadRawReply - { - Success = true, - CorrelationId = req.CorrelationId, - Samples = - [ - new HistorianSampleDto - { - ValueBytes = expectedValue, - Quality = 192, // Good - TimestampUtcTicks = expectedTicks, - }, - ], - }; - await writer.WriteAsync(MessageKind.ReadRawReply, reply, cts.Token); - }, cts.Token); -#pragma warning restore xUnit1051 - - // 3. Construct the client via the PUBLIC ctor (no ForTests factory). - var opts = new WonderwareHistorianClientOptions( - Host: "127.0.0.1", - Port: boundPort, - SharedSecret: Secret, - ConnectTimeout: TimeSpan.FromSeconds(5), - CallTimeout: TimeSpan.FromSeconds(5)) - { - UseTls = false, - }; - - WonderwareHistorianClient? client = null; - try - { - client = new WonderwareHistorianClient(opts); - - var result = await client.ReadRawAsync( - "Tank.Level", - new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc), - 100, cts.Token); - - // 4. Assert the known sample came back. - result.Samples.Count.ShouldBe(1); - result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good - result.Samples[0].SourceTimestampUtc.ShouldBe(new DateTime(expectedTicks, DateTimeKind.Utc)); - result.Samples[0].Value.ShouldBe(99.0); - - await serverTask; - } - finally - { - if (client is not null) await client.DisposeAsync(); - listener.Stop(); - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj deleted file mode 100644 index 9315b6ed..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net10.0 - enable - enable - false - true - ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/AahClientManagedAlarmEventWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/AahClientManagedAlarmEventWriterTests.cs deleted file mode 100644 index 9bc80051..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/AahClientManagedAlarmEventWriterTests.cs +++ /dev/null @@ -1,270 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// PR C.1 — pins the trinary outcome → IPC bool[] mapping that the sidecar uses - /// on the WriteAlarmEvents reply. Per-event outcomes: - /// Ack → true, RetryPlease → false, PermanentFail → false. - /// The sender's B.4 widens the IPC bool back into the trinary outcome at the - /// IPC boundary using structured diagnostics; the wire intentionally collapses - /// to "ok / not-ok". - /// - [Trait("Category", "Unit")] - public sealed class AahClientManagedAlarmEventWriterTests - { - /// Verifies that an empty batch returns an empty array without invoking the backend. - [Fact] - public async Task Empty_batch_returns_empty_array_without_invoking_backend() - { - var backend = new RecordingBackend(_ => throw new InvalidOperationException("must not invoke for empty input")); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var result = await writer.WriteAsync(Array.Empty(), CancellationToken.None); - - result.ShouldBeEmpty(); - backend.Calls.ShouldBe(0); - } - - /// Verifies that a single acknowledgment outcome maps to true. - [Fact] - public async Task Single_ack_outcome_maps_to_true() - { - var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray()); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None); - - result.ShouldBe(new[] { true }); - } - - /// Verifies that a mixed batch preserves per-slot outcome ordering. - [Fact] - public async Task Mixed_batch_preserves_per_slot_ordering() - { - // Ack / Retry / Permanent / Ack — the sender uses positional matching against - // its queue, so every slot must hit the exact bool corresponding to its input. - var backend = new RecordingBackend(_ => new[] - { - AlarmHistorianWriteOutcome.Ack, - AlarmHistorianWriteOutcome.RetryPlease, - AlarmHistorianWriteOutcome.PermanentFail, - AlarmHistorianWriteOutcome.Ack, - }); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var result = await writer.WriteAsync( - new[] { Event("E1"), Event("E2"), Event("E3"), Event("E4") }, - CancellationToken.None); - - result.ShouldBe(new[] { true, false, false, true }); - } - - /// Verifies that backend exceptions mark the whole batch as RetryPlease. - [Fact] - public async Task Backend_exception_marks_whole_batch_RetryPlease() - { - var backend = new RecordingBackend(_ => throw new InvalidOperationException("cluster unreachable")); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var result = await writer.WriteAsync( - new[] { Event("E1"), Event("E2"), Event("E3") }, - CancellationToken.None); - - // Whole batch must end up as "not ok" (RetryPlease at the trinary layer) — - // dropping a transiently-failed batch corrupts the sender's queue. - result.ShouldBe(new[] { false, false, false }); - } - - /// Verifies that cancellation propagates from the backend. - [Fact] - public async Task Cancellation_propagates_from_backend() - { - var backend = new RecordingBackend(_ => throw new OperationCanceledException()); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var ex = await Should.ThrowAsync(() => - writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None)); - ex.ShouldNotBeNull(); - } - - /// Verifies that a backend returning the wrong outcome count degrades to RetryPlease. - [Fact] - public async Task Backend_returning_wrong_count_degrades_to_RetryPlease() - { - // Backend returns more outcomes than inputs — defensive degrade rather than - // letting a backend bug desync the sender's queue accounting. - var backend = new RecordingBackend(_ => new[] - { - AlarmHistorianWriteOutcome.Ack, - AlarmHistorianWriteOutcome.Ack, - }); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var result = await writer.WriteAsync(new[] { Event("E1") }, CancellationToken.None); - - result.ShouldBe(new[] { false }); - } - - /// Verifies that a large batch with all acknowledgments returns all true outcomes. - /// The batch size to test. - [Theory] - [InlineData(100)] - [InlineData(1000)] - public async Task Large_batch_all_ack_returns_all_true(int batchSize) - { - // Spec: "1 / 100 / 1000 events through a fake aahClientManaged writer; - // assert per-row outcome list parallel to input order." - var backend = new RecordingBackend(events => events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray()); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var batch = Enumerable.Range(0, batchSize) - .Select(i => Event($"E{i}")) - .ToArray(); - - var result = await writer.WriteAsync(batch, CancellationToken.None); - - result.Length.ShouldBe(batchSize); - result.ShouldAllBe(ok => ok); - backend.Calls.ShouldBe(1); - } - - /// Verifies that a large batch with alternating outcomes preserves positional ordering. - /// The batch size to test. - [Theory] - [InlineData(100)] - [InlineData(1000)] - public async Task Large_batch_alternating_outcomes_are_positionally_correct(int batchSize) - { - // Verifies that per-row outcome ordering is preserved for large batches; - // a backend that returns the outcomes in a different allocation order would - // fail this test if the writer incorrectly indexing outcomes. - var backend = new RecordingBackend(events => - events.Select((_, i) => i % 2 == 0 - ? AlarmHistorianWriteOutcome.Ack - : AlarmHistorianWriteOutcome.RetryPlease).ToArray()); - var writer = new AahClientManagedAlarmEventWriter(backend); - - var batch = Enumerable.Range(0, batchSize).Select(i => Event($"E{i}")).ToArray(); - var result = await writer.WriteAsync(batch, CancellationToken.None); - - result.Length.ShouldBe(batchSize); - for (var i = 0; i < result.Length; i++) - { - var expected = i % 2 == 0; - result[i].ShouldBe(expected, $"slot {i}: expected {expected}"); - } - } - - /// Verifies that retry then succeed correctly simulates cluster failover. - [Fact] - public async Task Backend_retry_then_succeed_simulates_cluster_failover() - { - // Spec: "Cluster failover: primary node returns BadCommunicationError; - // picker rotates to secondary; assert eventual success." - // - // The real cluster-failover path is internal to SdkAlarmHistorianWriteBackend - // (which is rig-gated) and is exercised at the HistorianClusterEndpointPicker - // level in HistorianClusterEndpointPickerTests. Here we test the - // AahClientManagedAlarmEventWriter's handling of a backend that returns - // RetryPlease on the first call (primary-node failure) and Ack on the - // second call (secondary-node success), confirming the IPC layer correctly - // propagates the trinary outcome across two separate drain ticks. - var callCount = 0; - var backend = new RecordingBackend(events => - { - callCount++; - if (callCount == 1) - { - // First call: simulate communication error (isCommunicationError=true) - // which produces RetryPlease — equivalent to primary node failing. - return events.Select(_ => AlarmHistorianWriteOutcome.RetryPlease).ToArray(); - } - // Second call (after cluster picker has rotated to secondary): Ack. - return events.Select(_ => AlarmHistorianWriteOutcome.Ack).ToArray(); - }); - var writer = new AahClientManagedAlarmEventWriter(backend); - var batch = new[] { Event("E1"), Event("E2") }; - - // First drain tick: primary "fails" → all RetryPlease (false at IPC layer). - var firstResult = await writer.WriteAsync(batch, CancellationToken.None); - firstResult.ShouldBe(new[] { false, false }); - - // Second drain tick: secondary succeeds → all Ack (true at IPC layer). - var secondResult = await writer.WriteAsync(batch, CancellationToken.None); - secondResult.ShouldBe(new[] { true, true }); - - backend.Calls.ShouldBe(2); - } - - /// Verifies outcome mapping across various HRESULT and error condition combinations. - /// The HRESULT code to test. - /// Whether the error is a communication error. - /// Whether the input is malformed. - /// The expected outcome. - [Theory] - // hresult 0 + clean → Ack - [InlineData(0, false, false, AlarmHistorianWriteOutcome.Ack)] - // hresult 0 but malformed → PermanentFail (malformed wins) - [InlineData(0, false, true, AlarmHistorianWriteOutcome.PermanentFail)] - // non-zero hresult + comm error → RetryPlease - [InlineData(unchecked((int)0x80131500), true, false, AlarmHistorianWriteOutcome.RetryPlease)] - // non-zero hresult, no comm flag, no malformed → conservative RetryPlease - [InlineData(unchecked((int)0x80131500), false, false, AlarmHistorianWriteOutcome.RetryPlease)] - // any malformed input → PermanentFail regardless of hresult - [InlineData(unchecked((int)0x80131500), true, true, AlarmHistorianWriteOutcome.PermanentFail)] - public void MapOutcome_table(int hresult, bool isCommunicationError, bool isMalformedInput, AlarmHistorianWriteOutcome expected) - { - AahClientManagedAlarmEventWriter - .MapOutcome(hresult, isCommunicationError, isMalformedInput) - .ShouldBe(expected); - } - - private static AlarmHistorianEventDto Event(string id) => new AlarmHistorianEventDto - { - EventId = id, - SourceName = "Tank01", - ConditionId = "Tank01.Level.HiHi", - AlarmType = "AnalogLimitAlarm.HiHi", - Message = "Tank 01 high-high level", - Severity = 750, - EventTimeUtcTicks = DateTime.UtcNow.Ticks, - AckComment = null, - }; - - /// Test double that records calls and returns outcomes via a delegate. - private sealed class RecordingBackend : IAlarmHistorianWriteBackend - { - private readonly Func _produce; - - /// Gets the number of calls recorded. - public int Calls { get; private set; } - - /// Initializes a new instance of the class. - /// A delegate that produces outcomes for the given events. - public RecordingBackend(Func produce) - { - _produce = produce; - } - - /// Records a call and returns outcomes from the delegate. - /// The events to write. - /// Cancellation token. - /// The outcomes produced by the delegate. - public Task WriteBatchAsync( - AlarmHistorianEventDto[] events, CancellationToken cancellationToken) - { - Calls++; - return Task.FromResult(_produce(events)); - } - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianClusterEndpointPickerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianClusterEndpointPickerTests.cs deleted file mode 100644 index c97007ad..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianClusterEndpointPickerTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Linq; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - [Trait("Category", "Unit")] - public sealed class HistorianClusterEndpointPickerTests - { - private static HistorianConfiguration Config(params string[] nodes) => new() - { - ServerName = "ignored", - ServerNames = nodes.ToList(), - FailureCooldownSeconds = 60, - }; - - /// Verifies that a single-node configuration falls back to ServerName when ServerNames is empty. - [Fact] - public void Single_node_config_falls_back_to_ServerName_when_ServerNames_empty() - { - var cfg = new HistorianConfiguration { ServerName = "only-node", ServerNames = new() }; - var p = new HistorianClusterEndpointPicker(cfg); - p.NodeCount.ShouldBe(1); - p.GetHealthyNodes().ShouldBe(new[] { "only-node" }); - } - - /// Verifies that a failed node enters cooldown and is skipped from the healthy nodes list. - [Fact] - public void Failed_node_enters_cooldown_and_is_skipped() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now); - - p.MarkFailed("a", "boom"); - p.GetHealthyNodes().ShouldBe(new[] { "b" }); - } - - /// Verifies that the cooldown period expires after the configured time window. - [Fact] - public void Cooldown_expires_after_configured_window() - { - var clock = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => clock); - p.MarkFailed("a", "boom"); - p.GetHealthyNodes().ShouldBe(new[] { "b" }); - - clock = clock.AddSeconds(61); - p.GetHealthyNodes().ShouldBe(new[] { "a", "b" }); - } - - /// Verifies that marking a node healthy immediately clears its cooldown. - [Fact] - public void MarkHealthy_immediately_clears_cooldown() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var p = new HistorianClusterEndpointPicker(Config("a"), () => now); - p.MarkFailed("a", "boom"); - p.GetHealthyNodes().ShouldBeEmpty(); - p.MarkHealthy("a"); - p.GetHealthyNodes().ShouldBe(new[] { "a" }); - } - - /// Verifies that when all nodes are in cooldown, an empty healthy list is returned. - [Fact] - public void All_nodes_in_cooldown_returns_empty_healthy_list() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now); - p.MarkFailed("a", "x"); - p.MarkFailed("b", "y"); - p.GetHealthyNodes().ShouldBeEmpty(); - p.NodeCount.ShouldBe(2); - } - - /// Verifies that a snapshot reports failure count and the last error message. - [Fact] - public void Snapshot_reports_failure_count_and_last_error() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var p = new HistorianClusterEndpointPicker(Config("a"), () => now); - p.MarkFailed("a", "first"); - p.MarkFailed("a", "second"); - - var snap = p.SnapshotNodeStates().Single(); - snap.FailureCount.ShouldBe(2); - snap.LastError.ShouldBe("second"); - snap.IsHealthy.ShouldBeFalse(); - snap.CooldownUntil.ShouldNotBeNull(); - } - - /// Verifies that duplicate hostnames are deduplicated case-insensitively. - [Fact] - public void Duplicate_hostnames_are_deduplicated_case_insensitively() - { - var p = new HistorianClusterEndpointPicker(Config("NodeA", "nodea", "NodeB")); - p.NodeCount.ShouldBe(2); - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceConnectFailoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceConnectFailoverTests.cs deleted file mode 100644 index 799fead6..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceConnectFailoverTests.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; - -/// -/// Driver.Historian.Wonderware-012 coverage — pins 's -/// connect-failover / cooldown loop via a fake . -/// A live is never instantiated; the fake throws on every -/// attempt so the read path surfaces the connect failure without touching the SDK. -/// -[Trait("Category", "Unit")] -public sealed class HistorianDataSourceConnectFailoverTests -{ - /// Verifies that ReadRaw throws when no nodes are healthy. - [Fact] - public async Task ReadRaw_when_no_nodes_are_healthy_throws_so_IPC_surfaces_Success_false() - { - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerNames = new List { "node-a" }, - FailureCooldownSeconds = 60, - // Disable the outer request timeout so the test doesn't race the connect failure - // against the timeout (we want the connect failure path, not a TimeoutException). - RequestTimeoutSeconds = 0, - }; - var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory()); - - // Read methods used to swallow the connect exception and return an empty list with - // Success=true; the fix re-throws so the IPC layer surfaces Success=false. The - // exception must therefore propagate. - await Should.ThrowAsync(() => ds.ReadRawAsync( - "Tank.Level", - new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc), - maxValues: 100, - CancellationToken.None)); - } - - /// Verifies that ReadRaw tries each cluster node in order. - [Fact] - public async Task ReadRaw_tries_each_cluster_node_in_order_until_one_succeeds_or_all_fail() - { - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerNames = new List { "node-a", "node-b", "node-c" }, - FailureCooldownSeconds = 60, - RequestTimeoutSeconds = 0, - }; - var factory = new TrackingThrowingConnectionFactory(); - var ds = new HistorianDataSource(cfg, factory); - - await Should.ThrowAsync(() => ds.ReadRawAsync( - "Tank.Level", - new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc), - maxValues: 100, - CancellationToken.None)); - - // All three candidates must be attempted in the configured order before the - // connect-loop gives up. - factory.AttemptedNodes.ShouldBe(new[] { "node-a", "node-b", "node-c" }); - } - - /// Verifies that failed nodes are marked in cooldown and not retried immediately. - [Fact] - public async Task ReadRaw_marks_failed_nodes_in_cooldown_so_a_subsequent_call_sees_no_healthy_nodes() - { - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerNames = new List { "node-a", "node-b" }, - FailureCooldownSeconds = 60, - RequestTimeoutSeconds = 0, - }; - var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory()); - - await Should.ThrowAsync(() => ds.ReadRawAsync( - "Tank.Level", - DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, - maxValues: 100, CancellationToken.None)); - - var snap = ds.GetHealthSnapshot(); - snap.NodeCount.ShouldBe(2); - snap.HealthyNodeCount.ShouldBe(0, "both nodes failed and entered cooldown after the connect attempts"); - snap.ProcessConnectionOpen.ShouldBeFalse(); - snap.ActiveProcessNode.ShouldBeNull(); - } - - /// Verifies that ReadEvents uses a separate event connection path. - [Fact] - public async Task ReadEvents_uses_a_separate_event_connection_path() - { - // ReadEventsAsync uses _eventConnection / EnsureEventConnected — a different - // codepath than ReadRawAsync. Symmetric test to pin the dual-connection design. - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerNames = new List { "node-a" }, - FailureCooldownSeconds = 60, - RequestTimeoutSeconds = 0, - }; - var factory = new TrackingThrowingConnectionFactory(); - var ds = new HistorianDataSource(cfg, factory); - - await Should.ThrowAsync(() => ds.ReadEventsAsync( - sourceName: "Tank.HiHi", - DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, - maxEvents: 100, CancellationToken.None)); - - factory.AttemptedTypes.ShouldContain(HistorianConnectionType.Event, - "event reads must open an Event-typed connection"); - factory.AttemptedNodes.ShouldBe(new[] { "node-a" }); - } - - // ── helpers ────────────────────────────────────────────────────────── - - private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory - { - /// - /// Simulates a connection failure by throwing an exception. - /// - /// The historian configuration. - /// The connection type. - /// Whether to open a read-only connection. - public HistorianAccess CreateAndConnect( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) - => throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); - } - - private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory - { - /// Gets the list of node names that were attempted. - public List AttemptedNodes { get; } = new(); - /// Gets the list of connection types that were attempted. - public List AttemptedTypes { get; } = new(); - - /// - /// Tracks connection attempts and simulates a connection failure. - /// - /// The historian configuration. - /// The connection type. - /// Whether to open a read-only connection. - public HistorianAccess CreateAndConnect( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) - { - AttemptedNodes.Add(config.ServerName); - AttemptedTypes.Add(type); - throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceHealthSnapshotTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceHealthSnapshotTests.cs deleted file mode 100644 index bc98f888..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceHealthSnapshotTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Reflection; -using ArchestrA; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; - -/// -/// Driver.Historian.Wonderware-005 regression tests for . -/// The active-node strings and the connection-open booleans were published under different -/// locks, so a snapshot could observe an internally inconsistent pairing (open with no node, -/// or closed with a non-null node). The fix derives the open booleans from the same field -/// that is published under the same lock so the snapshot is self-consistent by construction. -/// -[Trait("Category", "Unit")] -public sealed class HistorianDataSourceHealthSnapshotTests -{ - /// - /// Drives the "half-published" state directly via reflection: set _connection - /// to a non-null sentinel but leave _activeProcessNode null. The snapshot must - /// report ProcessConnectionOpen = false and ActiveProcessNode = null - /// consistently — never a mismatch. - /// - [Fact] - public void Snapshot_with_connection_set_but_active_node_null_is_consistent() - { - var ds = new HistorianDataSource( - new HistorianConfiguration { Enabled = true, ServerName = "h1" }); - - SetField(ds, "_connection", new HistorianAccess()); - SetField(ds, "_activeProcessNode", (string?)null); - - var snap = ds.GetHealthSnapshot(); - (snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue( - "snapshot must not advertise open with no node — picks one source of truth"); - } - - /// - /// Symmetric case for the event connection. - /// - [Fact] - public void Snapshot_with_event_connection_set_but_active_node_null_is_consistent() - { - var ds = new HistorianDataSource( - new HistorianConfiguration { Enabled = true, ServerName = "h1" }); - - SetField(ds, "_eventConnection", new HistorianAccess()); - SetField(ds, "_activeEventNode", (string?)null); - - var snap = ds.GetHealthSnapshot(); - (snap.EventConnectionOpen == (snap.ActiveEventNode != null)).ShouldBeTrue( - "snapshot must not advertise event open with no node"); - } - - /// - /// The other direction: connection cleared but node still populated (the failure path - /// between the two field clears). The snapshot must still pair them consistently. - /// - [Fact] - public void Snapshot_with_connection_cleared_but_active_node_populated_is_consistent() - { - var ds = new HistorianDataSource( - new HistorianConfiguration { Enabled = true, ServerName = "h1" }); - - SetField(ds, "_connection", (HistorianAccess?)null); - SetField(ds, "_activeProcessNode", "node-stale"); - - var snap = ds.GetHealthSnapshot(); - (snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue( - "snapshot must not advertise closed with a node still set"); - } - - /// - /// Steady-state happy path: both fields populated — snapshot reports both consistently. - /// - [Fact] - public void Snapshot_with_both_fields_populated_reports_open_and_active_node() - { - var ds = new HistorianDataSource( - new HistorianConfiguration { Enabled = true, ServerName = "h1" }); - - SetField(ds, "_connection", new HistorianAccess()); - SetField(ds, "_activeProcessNode", "h1"); - - var snap = ds.GetHealthSnapshot(); - snap.ProcessConnectionOpen.ShouldBeTrue(); - snap.ActiveProcessNode.ShouldBe("h1"); - } - - /// - /// Steady-state default (no connect attempted): both null. - /// - [Fact] - public void Snapshot_with_default_fields_reports_closed_with_no_active_node() - { - var ds = new HistorianDataSource( - new HistorianConfiguration { Enabled = true, ServerName = "h1" }); - - var snap = ds.GetHealthSnapshot(); - snap.ProcessConnectionOpen.ShouldBeFalse(); - snap.ActiveProcessNode.ShouldBeNull(); - snap.EventConnectionOpen.ShouldBeFalse(); - snap.ActiveEventNode.ShouldBeNull(); - } - - private static void SetField(object target, string name, object? value) - { - var f = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); - f.ShouldNotBeNull($"private field '{name}' must exist on {target.GetType().Name}"); - f!.SetValue(target, value); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceRequestTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceRequestTimeoutTests.cs deleted file mode 100644 index 66003e5d..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceRequestTimeoutTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; - -/// -/// Driver.Historian.Wonderware-010 regression. -/// was documented as the "outer safety timeout applied to sync-over-async Historian -/// operations" but was never read or enforced — a hung StartQuery or a slow -/// MoveNext could block the single pipe-server connection thread indefinitely. -/// The fix wires it into the read paths via a linked -/// so the documented safety net actually exists. -/// -/// The SDK-touching read methods cannot be unit-driven without a live AVEVA Historian. -/// This test pins the helper that derives the effective timeout from the config — the -/// read methods invoke that helper, so a regression in either the helper or the wiring -/// would break the test. -/// -[Trait("Category", "Unit")] -public sealed class HistorianDataSourceRequestTimeoutTests -{ - /// Verifies default request timeout is 60 seconds. - [Fact] - public void Default_request_timeout_is_60_seconds() - { - new HistorianConfiguration().RequestTimeoutSeconds.ShouldBe(60); - } - - /// Verifies positive request timeout values are applied correctly. - [Fact] - public void Positive_request_timeout_is_used_verbatim() - { - InvokeBuildLinkedTokenSource( - new HistorianConfiguration { RequestTimeoutSeconds = 30 }, - CancellationToken.None, - out var cts); - cts.ShouldNotBeNull(); - // The helper must wire CancelAfter — easiest cross-check is to observe that the - // returned CTS is NOT already cancelled, and that disposing it is safe. - cts!.IsCancellationRequested.ShouldBeFalse(); - cts.Dispose(); - } - - /// Verifies zero or negative timeout values disable the outer safety timeout. - [Fact] - public void Zero_or_negative_request_timeout_is_treated_as_no_timeout() - { - // A zero/negative value means "no outer timeout" — the helper must still return a - // linked CTS so callers can use one code path, but it must not auto-cancel. - InvokeBuildLinkedTokenSource( - new HistorianConfiguration { RequestTimeoutSeconds = 0 }, - CancellationToken.None, - out var cts); - cts.ShouldNotBeNull(); - cts!.IsCancellationRequested.ShouldBeFalse(); - // Give the runtime a moment — a misconfigured CancelAfter(0) would fire immediately. - Thread.Sleep(50); - cts.IsCancellationRequested.ShouldBeFalse("RequestTimeoutSeconds <= 0 must not auto-cancel"); - cts.Dispose(); - } - - /// Verifies short timeout values correctly fire cancellation on the linked token. - [Fact] - public async Task Small_timeout_cancels_the_linked_token() - { - // 50 ms timeout — sleep 250 ms then assert the linked CTS has fired. - InvokeBuildLinkedTokenSource( - new HistorianConfiguration { RequestTimeoutSeconds = 1 }, // smallest non-zero whole-second value - CancellationToken.None, - out var cts); - cts.ShouldNotBeNull(); - - // The wall-clock cost of waiting a full second per test is acceptable — this - // pins the actual CancelAfter wiring rather than just the conditional logic. - await Task.Delay(1500); - cts!.IsCancellationRequested.ShouldBeTrue("RequestTimeoutSeconds=1 must cancel within 1.5s"); - cts.Dispose(); - } - - /// Verifies caller's cancellation token propagates to the linked token. - [Fact] - public void Inbound_cancellation_propagates_into_the_linked_token() - { - using var outer = new CancellationTokenSource(); - InvokeBuildLinkedTokenSource( - new HistorianConfiguration { RequestTimeoutSeconds = 60 }, - outer.Token, - out var cts); - cts.ShouldNotBeNull(); - cts!.IsCancellationRequested.ShouldBeFalse(); - - outer.Cancel(); - cts.IsCancellationRequested.ShouldBeTrue("cancelling the caller's CT must cancel the linked CTS"); - cts.Dispose(); - } - - private static void InvokeBuildLinkedTokenSource( - HistorianConfiguration cfg, CancellationToken ct, out CancellationTokenSource? cts) - { - // The helper is internal so the InternalsVisibleTo on the data-source project lets - // us bind to it directly. Reflection keeps the test resilient if the method name is - // ever shortened. - var method = typeof(HistorianDataSource) - .GetMethod("BuildRequestCts", BindingFlags.Static | BindingFlags.NonPublic); - method.ShouldNotBeNull( - "HistorianDataSource.BuildRequestCts must exist — wires RequestTimeoutSeconds into the read paths"); - cts = (CancellationTokenSource?)method!.Invoke(null, new object[] { cfg, ct }); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceStartQueryClassificationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceStartQueryClassificationTests.cs deleted file mode 100644 index 32528255..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceStartQueryClassificationTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using ArchestrA; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; - -/// -/// Driver.Historian.Wonderware-008 regression. The previous implementation unconditionally -/// called HandleConnectionError() whenever StartQuery returned false, -/// which tore down the (relatively expensive) shared SDK connection on a query-class error -/// such as a bad tag name. A burst of bad-tag queries could therefore push an otherwise -/// healthy cluster node into cooldown via the picker's MarkFailed. The fix -/// classifies the SDK error code: connection-class codes drop the connection; query-class -/// codes leave it intact. -/// -[Trait("Category", "Unit")] -public sealed class HistorianDataSourceStartQueryClassificationTests -{ - // ── Connection-class codes — the connection should be reset ─────────── - - /// Verifies that connection-class error codes are classified as connection errors. - /// The historian error code to test. - [Theory] - [InlineData(HistorianAccessError.ErrorValue.FailedToConnect)] - [InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession)] - [InlineData(HistorianAccessError.ErrorValue.NoReply)] - [InlineData(HistorianAccessError.ErrorValue.NotReady)] - [InlineData(HistorianAccessError.ErrorValue.NotInitialized)] - [InlineData(HistorianAccessError.ErrorValue.Stopping)] - [InlineData(HistorianAccessError.ErrorValue.Win32Exception)] - [InlineData(HistorianAccessError.ErrorValue.InvalidResponse)] - public void Connection_class_codes_are_classified_as_connection_errors(HistorianAccessError.ErrorValue code) - { - HistorianDataSource.IsConnectionClassError(code).ShouldBeTrue( - $"{code} is a connection/server failure — the SDK connection should be reset"); - } - - // ── Query-class codes — the connection should NOT be reset ──────────── - - /// Verifies that query-class error codes are NOT classified as connection errors. - /// The historian error code to test. - [Theory] - [InlineData(HistorianAccessError.ErrorValue.InvalidArgument)] // bad tag name, etc. - [InlineData(HistorianAccessError.ErrorValue.ValidationFailed)] // bad query args - [InlineData(HistorianAccessError.ErrorValue.NotApplicable)] // wrong tag kind for query - [InlineData(HistorianAccessError.ErrorValue.NotImplemented)] // unsupported aggregate - [InlineData(HistorianAccessError.ErrorValue.NoData)] // empty range - public void Query_class_codes_are_NOT_classified_as_connection_errors(HistorianAccessError.ErrorValue code) - { - HistorianDataSource.IsConnectionClassError(code).ShouldBeFalse( - $"{code} is a query payload problem — must NOT tear down the SDK connection"); - } - - // ── Driver.Historian.Wonderware-014: the at-time loop must classify a per-timestamp - // StartQuery failure the same way the raw / aggregate / event paths do. The SDK - // HistoryQuery type is sealed-by-non-virtual + has no interface, so the loop itself - // can't be driven offline; the per-failure decision is therefore extracted into a - // pure helper that the at-time loop calls and these tests pin directly. ────────── - - /// - /// A connection-class StartQuery error in the at-time loop must signal "reset the - /// connection and abort the read" (true) — not silently record a Bad sample and keep - /// hammering the dead connection for every remaining timestamp. - /// - /// The connection-class error code. - [Theory] - [InlineData(HistorianAccessError.ErrorValue.FailedToConnect)] - [InlineData(HistorianAccessError.ErrorValue.NoReply)] - [InlineData(HistorianAccessError.ErrorValue.NotReady)] - public void AtTime_StartQuery_failure_with_connection_class_code_requests_connection_reset( - HistorianAccessError.ErrorValue code) - { - var error = new HistorianAccessError { ErrorCode = code }; - HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeTrue( - $"{code} is a connection failure — the at-time loop must reset the connection, not record Bad"); - } - - /// - /// A query-class StartQuery error (or a missing error) in the at-time loop must NOT - /// reset the connection (false): a single bad/empty timestamp records a per-timestamp - /// Bad sample and continues to the next without tearing down the shared connection. - /// - /// The query-class error code. - [Theory] - [InlineData(HistorianAccessError.ErrorValue.InvalidArgument)] - [InlineData(HistorianAccessError.ErrorValue.NoData)] - [InlineData(HistorianAccessError.ErrorValue.NotApplicable)] - public void AtTime_StartQuery_failure_with_query_class_code_does_not_request_reset( - HistorianAccessError.ErrorValue code) - { - var error = new HistorianAccessError { ErrorCode = code }; - HistorianDataSource.ShouldResetConnectionForStartQueryFailure(error).ShouldBeFalse( - $"{code} is a query/no-data problem — the at-time loop keeps the connection and records Bad"); - } - - /// A null error defaults to query-class (no reset) — the caller still records a Bad sample. - [Fact] - public void AtTime_StartQuery_failure_with_null_error_defaults_to_no_reset() - { - HistorianDataSource.ShouldResetConnectionForStartQueryFailure(null).ShouldBeFalse( - "a null error must not be promoted to a connection reset"); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceValueAndAggregateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceValueAndAggregateTests.cs deleted file mode 100644 index 4aaa1554..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceValueAndAggregateTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Runtime.Serialization; -using ArchestrA; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; - -/// -/// Driver.Historian.Wonderware-012 coverage — pins the two static helpers on -/// that previously had no direct tests: -/// (the string-vs-numeric heuristic -/// for the raw + at-time read paths) and -/// (the aggregate-column dispatch). The SDK HistoryQueryResult initialises internal -/// state lazily on first property access, which makes it impractical to fake via -/// ; the heuristic was therefore -/// refactored into an SDK-independent overload that the tests drive directly. -/// -[Trait("Category", "Unit")] -public sealed class HistorianDataSourceValueAndAggregateTests -{ - // ── SelectValueFromPair ─────────────────────────────────────────────── - - /// Verifies that numeric value is returned when StringValue is empty. - [Fact] - public void SelectValueFromPair_returns_numeric_value_when_StringValue_is_empty() - { - HistorianDataSource.SelectValueFromPair(42.5, string.Empty).ShouldBe(42.5); - } - - /// Verifies that numeric value is returned when Value is non-zero even if StringValue is populated. - [Fact] - public void SelectValueFromPair_returns_numeric_value_when_Value_is_non_zero_even_with_StringValue_populated() - { - // Tag is numeric and sampled non-zero; the SDK may still populate a formatted - // StringValue but the value path wins. - HistorianDataSource.SelectValueFromPair(3.14, "3.14").ShouldBe(3.14); - } - - /// Verifies that StringValue is returned when Value is zero and StringValue is non-empty. - [Fact] - public void SelectValueFromPair_returns_StringValue_when_Value_is_zero_and_StringValue_non_empty() - { - // String tags in the SDK always project Value=0 — that's the documented heuristic. - HistorianDataSource.SelectValueFromPair(0.0, "Ready").ShouldBe("Ready"); - } - - /// Verifies that numeric zero is returned when Value is zero and StringValue is empty. - [Fact] - public void SelectValueFromPair_returns_numeric_zero_when_Value_is_zero_and_StringValue_empty() - { - // Numeric tag legitimately samples zero, no formatted text — must remain numeric. - HistorianDataSource.SelectValueFromPair(0.0, string.Empty).ShouldBe(0.0); - } - - /// Verifies that null StringValue falls back to numeric value. - [Fact] - public void SelectValueFromPair_null_StringValue_falls_back_to_numeric() - { - HistorianDataSource.SelectValueFromPair(7.7, null).ShouldBe(7.7); - } - - /// Verifies the documented edge case where numeric zero with a formatted string returns the string. - [Fact] - public void SelectValueFromPair_documented_edge_case_numeric_zero_with_formatted_string_returns_string() - { - // The doc comment on SelectValue calls this out as a known SDK-binding edge case: - // "A numeric tag at exactly zero with a non-empty formatted StringValue (e.g. '0.00') - // would be mis-reported as a string". This test pins that documented behaviour so - // a future SDK upgrade that surfaces a real data-type field can replace the - // heuristic deliberately rather than by accident. - HistorianDataSource.SelectValueFromPair(0.0, "0.00").ShouldBe("0.00"); - } - - // ── ExtractAggregateValue ───────────────────────────────────────────── - - /// Verifies that aggregate value extraction dispatches correctly for known columns. - /// The aggregate result column name to extract. - /// The expected aggregate double value. - [Theory] - [InlineData("Average", 10.0)] - [InlineData("Minimum", 1.0)] - [InlineData("Maximum", 20.0)] - [InlineData("First", 2.0)] - [InlineData("Last", 8.0)] - [InlineData("StdDev", 1.5)] - public void ExtractAggregateValue_dispatches_known_columns(string column, double expected) - { - var result = NewAggregateResult(); - result.Average = 10.0; - result.Minimum = 1.0; - result.Maximum = 20.0; - result.ValueCount = 5; - result.First = 2.0; - result.Last = 8.0; - result.StdDev = 1.5; - - HistorianDataSource.ExtractAggregateValue(result, column).ShouldBe(expected); - } - - /// Verifies that ValueCount is dispatched to the uint field. - [Fact] - public void ExtractAggregateValue_ValueCount_dispatches_to_uint_field() - { - var result = NewAggregateResult(); - result.ValueCount = 42; - HistorianDataSource.ExtractAggregateValue(result, "ValueCount").ShouldBe(42.0); - } - - /// Verifies that an unknown column returns null. - [Fact] - public void ExtractAggregateValue_unknown_column_returns_null() - { - // Unknown column → null → IPC sample carries no value → client maps to BadNoData. - HistorianDataSource.ExtractAggregateValue(NewAggregateResult(), "NotAColumn").ShouldBeNull(); - } - - /// Verifies that aggregate value dispatch is case-sensitive. - [Fact] - public void ExtractAggregateValue_case_sensitive_dispatch() - { - // The switch is case-sensitive — "average" (lowercase) does NOT dispatch. Pinned so - // the canonical column-name casing is preserved across refactors. - var result = NewAggregateResult(); - result.Average = 99.0; - HistorianDataSource.ExtractAggregateValue(result, "average").ShouldBeNull(); - HistorianDataSource.ExtractAggregateValue(result, "Average").ShouldBe(99.0); - } - - private static AnalogSummaryQueryResult NewAggregateResult() - { - return (AnalogSummaryQueryResult)FormatterServices.GetUninitializedObject(typeof(AnalogSummaryQueryResult)); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianQualityMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianQualityMapperTests.cs deleted file mode 100644 index c8775e64..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianQualityMapperTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class HistorianQualityMapperTests -{ - /// - /// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path. - /// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to - /// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues - /// from sensor issues. After PR 12 every known subcode round-trips to its canonical - /// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper. - /// - /// The OPC DA quality code to map. - /// The expected canonical OPC UA StatusCode. - [Theory] - [InlineData((byte)192, 0x00000000u)] // Good - [InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride - [InlineData((byte)64, 0x40000000u)] // Uncertain - [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue - [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate - [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded - [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal - [InlineData((byte)0, 0x80000000u)] // Bad - [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError - [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected - [InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure - [InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure - [InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError - [InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService - [InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData - public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected) - { - HistorianQualityMapper.Map(quality).ShouldBe(expected); - } - - /// Verifies that unknown good-family quality codes fall back to plain Good. - /// The OPC DA quality byte to test. - [Theory] - [InlineData((byte)200)] // Good — unknown subcode in Good family - [InlineData((byte)255)] // Good — unknown - public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q) - { - HistorianQualityMapper.Map(q).ShouldBe(0x00000000u); - } - - /// Verifies that unknown uncertain-family quality codes fall back to plain Uncertain. - /// The OPC DA quality byte to test. - [Theory] - [InlineData((byte)100)] // Uncertain — unknown subcode - [InlineData((byte)150)] // Uncertain — unknown - public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q) - { - HistorianQualityMapper.Map(q).ShouldBe(0x40000000u); - } - - /// Verifies that unknown bad-family quality codes fall back to plain Bad. - /// The OPC DA quality byte to test. - [Theory] - [InlineData((byte)1)] // Bad — unknown subcode - [InlineData((byte)50)] // Bad — unknown - public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q) - { - HistorianQualityMapper.Map(q).ShouldBe(0x80000000u); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/SdkAlarmHistorianWriteBackendTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/SdkAlarmHistorianWriteBackendTests.cs deleted file mode 100644 index 59a9d80a..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/SdkAlarmHistorianWriteBackendTests.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// PR C.1 — covers , the aahClientManaged-bound - /// alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated - /// Live_* tests (D.1); the unit tests below pin the parts that are SDK-type-free: - /// - /// connection-unavailable → whole batch deferred as RetryPlease; - /// error-code mapping; - /// read-only-vs-write shaping. - /// - /// - [Trait("Category", "Unit")] - public sealed class SdkAlarmHistorianWriteBackendTests - { - // ── Connection-unavailable path (deterministic, no SDK load) ────────── - - /// Verifies that an empty batch returns an empty outcome array. - [Fact] - public async Task Empty_batch_returns_empty_array() - { - var backend = new SdkAlarmHistorianWriteBackend( - Config("any"), new ThrowingConnectionFactory()); - - var outcomes = await backend.WriteBatchAsync( - Array.Empty(), CancellationToken.None); - - outcomes.ShouldBeEmpty(); - } - - /// Verifies that when all nodes are unreachable, the entire batch is deferred as RetryPlease. - [Fact] - public async Task Unreachable_node_defers_whole_batch_as_RetryPlease() - { - // No node can be connected — the backend must defer every event so the - // lmxopcua-side SQLite store-and-forward sink retains the rows rather than - // dropping them. - var backend = new SdkAlarmHistorianWriteBackend( - Config("unreachable"), new ThrowingConnectionFactory()); - - var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") }; - var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None); - - outcomes.Length.ShouldBe(events.Length); - outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease); - } - - /// Verifies that a large batch with unreachable nodes returns one outcome per event. - [Fact] - public async Task Unreachable_node_large_batch_returns_one_outcome_per_event() - { - // Guards the outcome-array allocation: WriteBatchAsync must always return exactly - // as many outcomes as input events, even on the whole-batch-deferred path. - var backend = new SdkAlarmHistorianWriteBackend( - Config("unreachable"), new ThrowingConnectionFactory()); - - var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray(); - var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None); - - outcomes.Length.ShouldBe(1000); - outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease); - } - - /// Verifies that a connection failure marks the node as failed in the endpoint picker. - [Fact] - public async Task Connect_failure_marks_node_failed_in_picker() - { - // Every connect attempt throws → the picker should record the failure so the - // node enters cooldown (cluster-failover plumbing). - var cfg = Config("node-a"); - var picker = new HistorianClusterEndpointPicker(cfg); - var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker); - - await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None); - - picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown"); - } - - // ── ClassifyOutcome — error-code → outcome mapping ──────────────────── - - /// Verifies that error codes map to the expected write outcomes. - /// The historian access error code to classify. - /// The expected write outcome. - [Theory] - [InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)] - [InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)] - [InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)] - [InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)] - [InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)] - public void ClassifyOutcome_maps_error_code_to_expected_outcome( - HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected) - { - SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected); - } - - // ── ToHistorianEvent — EventId handling ─────────────────────────────── - - /// Verifies that a parseable event ID is used verbatim in the historian event. - [Fact] - public void ToHistorianEvent_parseable_event_id_is_used_verbatim() - { - // Sanity case: a real GUID round-trips into HistorianEvent.Id. - var id = Guid.Parse("12345678-1234-1234-1234-123456789abc"); - var dto = new AlarmHistorianEventDto - { - EventId = id.ToString(), - SourceName = "Tank01", - AlarmType = "AnalogLimitAlarm.HiHi", - EventTimeUtcTicks = DateTime.UtcNow.Ticks, - }; - -#pragma warning disable CS0618 - SdkAlarmHistorianWriteBackend.ToHistorianEvent(dto).Id.ShouldBe(id); -#pragma warning restore CS0618 - } - - /// Verifies that an unparseable event ID is synthesized as a unique non-empty GUID. - [Fact] - public void ToHistorianEvent_unparseable_event_id_synthesizes_unique_non_empty_Guid() - { - // Driver.Historian.Wonderware-004 regression: when EventId is not a parseable - // GUID (or is empty) the previous implementation silently left HistorianEvent.Id - // as Guid.Empty, so multiple alarms collided on the same id with no warning. - // The fix synthesizes a fresh Guid so every event still gets a unique identifier. - var dtoA = new AlarmHistorianEventDto - { - EventId = "not-a-guid", - SourceName = "Tank01", - AlarmType = "Active", - EventTimeUtcTicks = DateTime.UtcNow.Ticks, - }; - var dtoB = new AlarmHistorianEventDto - { - EventId = string.Empty, - SourceName = "Tank01", - AlarmType = "Active", - EventTimeUtcTicks = DateTime.UtcNow.Ticks, - }; - -#pragma warning disable CS0618 - var idA = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoA).Id; - var idB = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoB).Id; -#pragma warning restore CS0618 - - idA.ShouldNotBe(Guid.Empty, "unparseable EventId must not collapse to Guid.Empty"); - idB.ShouldNotBe(Guid.Empty, "empty EventId must not collapse to Guid.Empty"); - idA.ShouldNotBe(idB, "every event needs a unique synthesized id"); - } - - /// Verifies that a write-to-read-only-file error is classified as RetryPlease, not PermanentFail. - [Fact] - public void ClassifyOutcome_WriteToReadOnlyFile_is_RetryPlease_not_PermanentFail() - { - // Driver.Historian.Wonderware-001 regression: WriteToReadOnlyFile is a - // connection-configuration fault (the write session was opened without - // ReadOnly = false), NOT a malformed-event fault. Routing it to PermanentFail - // would dead-letter every alarm event in the batch on a misconfigured/regressed - // connection — data loss. It must be treated as a transient connection-class - // error so the events are deferred and retried once the connection is corrected. - SdkAlarmHistorianWriteBackend.ClassifyOutcome( - HistorianAccessError.ErrorValue.WriteToReadOnlyFile) - .ShouldBe(AlarmHistorianWriteOutcome.RetryPlease); - } - - // ── BuildConnectionArgs — read-only vs write shaping ────────────────── - - /// Verifies that a write connection is opened with ReadOnly set to false. - [Fact] - public void BuildConnectionArgs_write_connection_is_not_read_only() - { - // The alarm-event write path must open ReadOnly=false; AddStreamedValue on a - // read-only session fails with WriteToReadOnlyFile. - var args = SdkHistorianConnectionFactory.BuildConnectionArgs( - Config("h1"), HistorianConnectionType.Event, readOnly: false); - - args.ReadOnly.ShouldBeFalse(); - args.ConnectionType.ShouldBe(HistorianConnectionType.Event); - args.ServerName.ShouldBe("h1"); - } - - /// Verifies that a query connection is opened with ReadOnly set to true. - [Fact] - public void BuildConnectionArgs_query_connection_is_read_only() - { - var args = SdkHistorianConnectionFactory.BuildConnectionArgs( - Config("h1"), HistorianConnectionType.Process, readOnly: true); - - args.ReadOnly.ShouldBeTrue(); - args.ConnectionType.ShouldBe(HistorianConnectionType.Process); - } - - /// Verifies that non-integrated security credentials are preserved in connection arguments. - [Fact] - public void BuildConnectionArgs_non_integrated_security_carries_credentials() - { - var cfg = Config("h1"); - cfg.IntegratedSecurity = false; - cfg.UserName = "histuser"; - cfg.Password = "histpass"; - - var args = SdkHistorianConnectionFactory.BuildConnectionArgs( - cfg, HistorianConnectionType.Event, readOnly: false); - - args.IntegratedSecurity.ShouldBeFalse(); - args.UserName.ShouldBe("histuser"); - args.Password.ShouldBe("histpass"); - } - - // ── Rig-gated integration tests ─────────────────────────────────────── - // - // The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented; - // these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke. - - /// Verifies that a single alarm event roundtrip returns an Ack outcome. - [Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")] - public async Task Live_single_event_roundtrip_returns_Ack() - { - var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig()); - - var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None); - - outcomes.Length.ShouldBe(1); - outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack); - } - - /// Verifies that cluster failover rotates from a bad primary node to a secondary node. - [Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")] - public async Task Live_cluster_failover_primary_bad_rotates_to_secondary() - { - var cfg = new HistorianConfiguration - { - Enabled = true, - ServerNames = new List - { - "invalid-primary-node-deliberately-unreachable", - Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", - }, - Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), - IntegratedSecurity = true, - FailureCooldownSeconds = 5, - CommandTimeoutSeconds = 10, - }; - var backend = new SdkAlarmHistorianWriteBackend(cfg); - - var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None); - - outcomes.Length.ShouldBe(1); - outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack); - } - - // ── helpers ─────────────────────────────────────────────────────────── - - private static HistorianConfiguration Config(string server) => new HistorianConfiguration - { - Enabled = true, - ServerName = server, - Port = 32568, - IntegratedSecurity = true, - CommandTimeoutSeconds = 30, - FailureCooldownSeconds = 60, - }; - - private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto - { - EventId = id, - SourceName = "TestSource", - ConditionId = "TestSource.Level.HiHi", - AlarmType = "AnalogLimitAlarm.HiHi", - Message = "C.1 test alarm", - Severity = 500, - EventTimeUtcTicks = DateTime.UtcNow.Ticks, - AckComment = null, - }; - - private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration - { - Enabled = true, - ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost", - Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568), - IntegratedSecurity = true, - CommandTimeoutSeconds = 30, - FailureCooldownSeconds = 60, - }; - - private static int TryParseInt(string envName, int defaultValue) - { - var raw = Environment.GetEnvironmentVariable(envName); - return int.TryParse(raw, out var parsed) ? parsed : defaultValue; - } - - /// - /// Fake factory whose every connect attempt throws — drives the - /// connection-unavailable path without loading the native SDK. - /// - private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory - { - /// Creates and attempts to connect, always throwing a simulated connect failure. - /// The historian configuration specifying the target server. - /// The connection type (Process or Event). - /// Whether to open a read-only connection. - public HistorianAccess CreateAndConnect( - HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) - => throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/HistorianEventClassifierTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/HistorianEventClassifierTests.cs deleted file mode 100644 index c1899fed..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/HistorianEventClassifierTests.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc -{ - /// - /// Pins the sidecar's poison-event classifier and the per-event status mapping in - /// . A structurally-malformed alarm event is marked - /// Permanent (status 2) and excluded from the writer batch so the store-and-forward sink - /// dead-letters it immediately rather than looping to the retry cap; well-formed events - /// map to Ack (0) / Retry (1) from the writer's per-event bool result. - /// - [Trait("Category", "Unit")] - public sealed class HistorianEventClassifierTests - { - /// Verifies a blank source name is classified structurally malformed. - [Fact] - public void IsStructurallyMalformed_BlankSourceName_IsTrue() - { - var e = WellFormed(); - e.SourceName = " "; - - HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); - } - - /// Verifies a blank alarm type is classified structurally malformed. - [Fact] - public void IsStructurallyMalformed_BlankAlarmType_IsTrue() - { - var e = WellFormed(); - e.AlarmType = ""; - - HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); - } - - /// Verifies a non-positive event timestamp is classified structurally malformed. - /// The event timestamp in ticks to test. - [Theory] - [InlineData(0L)] - [InlineData(-1L)] - public void IsStructurallyMalformed_NonPositiveTimestamp_IsTrue(long ticks) - { - var e = WellFormed(); - e.EventTimeUtcTicks = ticks; - - HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue(); - } - - /// Verifies a well-formed event is not classified structurally malformed. - [Fact] - public void IsStructurallyMalformed_WellFormedEvent_IsFalse() - { - HistorianFrameHandler.IsStructurallyMalformed(WellFormed()).ShouldBeFalse(); - } - - /// - /// A mixed batch — one poison event then one well-formed event the writer acks — must - /// yield PerEventStatus = [2, 0]: the poison event is Permanent and excluded from the - /// writer batch, and only the well-formed event reaches the writer. - /// - [Fact] - public async Task Handler_MixedBatch_MarksPoisonPermanent_AndOnlyWritesWellFormed() - { - var poison = WellFormed(); - poison.EventId = "poison"; - poison.SourceName = ""; // structurally malformed - - var good = WellFormed(); - good.EventId = "good"; - - var fakeWriter = new RecordingAlarmEventWriter(_ => true); - var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); - - var req = new WriteAlarmEventsRequest { Events = new[] { poison, good }, CorrelationId = "c1" }; - var reply = await RoundTripAsync(handler, req); - - reply.Success.ShouldBeTrue(); - reply.PerEventStatus.ShouldBe(new byte[] { 2, 0 }); - reply.PerEventOk.ShouldBe(new[] { false, true }); - - // The writer only ever saw the well-formed event. - fakeWriter.Received.Count.ShouldBe(1); - fakeWriter.Received[0].EventId.ShouldBe("good"); - } - - /// - /// A well-formed event the writer reports as not-persisted maps to Retry (status 1), - /// not Permanent — only structurally-malformed events are Permanent. - /// - [Fact] - public async Task Handler_WriterReportsNotPersisted_MapsToRetry() - { - var good = WellFormed(); - good.EventId = "good"; - - var fakeWriter = new RecordingAlarmEventWriter(_ => false); - var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); - - var req = new WriteAlarmEventsRequest { Events = new[] { good }, CorrelationId = "c2" }; - var reply = await RoundTripAsync(handler, req); - - reply.Success.ShouldBeTrue(); - reply.PerEventStatus.ShouldBe(new byte[] { 1 }); - reply.PerEventOk.ShouldBe(new[] { false }); - } - - /// - /// An all-poison batch must short-circuit the writer entirely (no WriteAsync call) - /// and mark every slot Permanent. - /// - [Fact] - public async Task Handler_AllPoison_SkipsWriter_AllPermanent() - { - var p1 = WellFormed(); - p1.SourceName = ""; - var p2 = WellFormed(); - p2.AlarmType = ""; - - var fakeWriter = new RecordingAlarmEventWriter(_ => true); - var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter); - - var req = new WriteAlarmEventsRequest { Events = new[] { p1, p2 }, CorrelationId = "c3" }; - var reply = await RoundTripAsync(handler, req); - - reply.Success.ShouldBeTrue(); - reply.PerEventStatus.ShouldBe(new byte[] { 2, 2 }); - fakeWriter.Received.Count.ShouldBe(0); - } - - private static AlarmHistorianEventDto WellFormed() => new() - { - EventId = "ev", - SourceName = "Tank.HiHi", - ConditionId = "HiHi", - AlarmType = "LimitAlarm:Activated", - Message = "msg", - Severity = 700, - EventTimeUtcTicks = new DateTime(2026, 6, 18, 12, 0, 0, DateTimeKind.Utc).Ticks, - AckComment = null, - }; - - /// - /// Drives a WriteAlarmEvents request through the real frame handler over an in-memory - /// duplex stream pair and deserializes the reply the handler writes back. - /// - private static async Task RoundTripAsync( - HistorianFrameHandler handler, WriteAlarmEventsRequest req) - { - var capture = new MemoryStream(); - using var writer = new FrameWriter(capture, leaveOpen: true); - - var body = MessagePackSerializer.Serialize(req); - await handler.HandleAsync(MessageKind.WriteAlarmEventsRequest, body, writer, CancellationToken.None); - - capture.Position = 0; - using var reader = new FrameReader(capture, leaveOpen: true); - var frame = await reader.ReadFrameAsync(CancellationToken.None); - frame.ShouldNotBeNull(); - frame!.Value.Kind.ShouldBe(MessageKind.WriteAlarmEventsReply); - return MessagePackSerializer.Deserialize(frame.Value.Body); - } - - /// An that records the batch it received and returns a fixed verdict. - private sealed class RecordingAlarmEventWriter : IAlarmEventWriter - { - private readonly Func _verdict; - - /// Initializes a new instance with the given per-event verdict. - /// Maps each received event to its persisted/not-persisted result. - public RecordingAlarmEventWriter(Func verdict) => _verdict = verdict; - - /// The events the writer was handed, in order. - public List Received { get; } = new(); - - /// - public Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken) - { - Received.AddRange(events); - return Task.FromResult(events.Select(_verdict).ToArray()); - } - } - - /// - /// A read data source the WriteAlarmEvents path never touches — present only to - /// satisfy the ctor's non-null requirement. - /// - private sealed class StubHistorian : IHistorianDataSource - { - /// - public Task> ReadRawAsync( - string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default) - => throw new NotSupportedException(); - - /// - public Task> ReadAggregateAsync( - string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default) - => throw new NotSupportedException(); - - /// - public Task> ReadAtTimeAsync( - string tagName, DateTime[] timestamps, CancellationToken ct = default) - => throw new NotSupportedException(); - - /// - public Task> ReadEventsAsync( - string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default) - => throw new NotSupportedException(); - - /// - public HistorianHealthSnapshot GetHealthSnapshot() => throw new NotSupportedException(); - - /// - public void Dispose() { } - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs deleted file mode 100644 index 141e48b7..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/TcpRoundTripTests.cs +++ /dev/null @@ -1,297 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc; - -/// -/// Round-trip tests for added with the TCP transport. Each -/// scenario binds the server on 127.0.0.1:0, connects a real , -/// performs the Hello handshake, and exercises a request/reply over the wire framing — both -/// plaintext and over TLS. These target net48 and run on Windows in CI; on the macOS dev box -/// they only compile. -/// -public sealed class TcpRoundTripTests -{ - private static readonly ILogger Quiet = Logger.None; - - // Generous timeout so the deterministic tests don't hang CI if the server misbehaves. - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - - /// - /// Fake handler that echoes a fixed when it sees a - /// , mirroring the client correlation id. - /// - private sealed class EchoHandler : IFrameHandler - { - public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) - { - if (kind != MessageKind.ReadRawRequest) - return Task.CompletedTask; - - var request = MessagePackSerializer.Deserialize(body); - var reply = new ReadRawReply - { - CorrelationId = request.CorrelationId, - Success = true, - Samples = new[] - { - new HistorianSampleDto - { - ValueBytes = MessagePackSerializer.Serialize(42.0), - Quality = 192, - TimestampUtcTicks = new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc).Ticks, - }, - }, - }; - return writer.WriteAsync(MessageKind.ReadRawReply, reply, ct); - } - } - - /// Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key. - private static X509Certificate2 MakeSelfSignedCert() - { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false)); - using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); - // Round-trip through a PFX so the returned cert carries an exportable private key on net48. - var pfx = ephemeral.Export(X509ContentType.Pfx, "pw"); - return new X509Certificate2(pfx, "pw", X509KeyStorageFlags.Exportable); - } - - /// Performs the Hello handshake on the given stream and returns the deserialized ack. - private static async Task HelloAsync(Stream stream, string secret, CancellationToken ct) - { - using var writer = new FrameWriter(stream, leaveOpen: true); - using var reader = new FrameReader(stream, leaveOpen: true); - - await writer.WriteAsync(MessageKind.Hello, - new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test-client", SharedSecret = secret }, ct); - - var ackFrame = await reader.ReadFrameAsync(ct); - ackFrame.ShouldNotBeNull(); - ackFrame!.Value.Kind.ShouldBe(MessageKind.HelloAck); - return MessagePackSerializer.Deserialize(ackFrame.Value.Body); - } - - /// Wraps a connected client socket stream in an SslStream that pins the server cert thumbprint. - private static async Task ClientTlsAsync(NetworkStream inner, string expectedThumbprint, CancellationToken ct) - { - var ssl = new SslStream(inner, leaveInnerStreamOpen: false, - userCertificateValidationCallback: (_, cert, _, _) => - cert is not null && - string.Equals( - cert.GetCertHashString(), - expectedThumbprint, - StringComparison.OrdinalIgnoreCase)); - await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null, - enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); - return ssl; - } - - /// Plaintext: Hello (good secret) is accepted and a ReadRaw request is echoed back. - [Fact] - public async Task Plaintext_RoundTrip_HelloAcceptedAndRequestEchoed() - { - using var cts = new CancellationTokenSource(Timeout); - using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet); - var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); - - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); - var stream = client.GetStream(); - - var ack = await HelloAsync(stream, "shh", cts.Token); - ack.Accepted.ShouldBeTrue(); - - using var writer = new FrameWriter(stream, leaveOpen: true); - using var reader = new FrameReader(stream, leaveOpen: true); - - await writer.WriteAsync(MessageKind.ReadRawRequest, - new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "corr-1" }, cts.Token); - - var replyFrame = await reader.ReadFrameAsync(cts.Token); - replyFrame.ShouldNotBeNull(); - replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply); - var reply = MessagePackSerializer.Deserialize(replyFrame.Value.Body); - reply.Success.ShouldBeTrue(); - reply.CorrelationId.ShouldBe("corr-1"); - reply.Samples.Length.ShouldBe(1); - MessagePackSerializer.Deserialize(reply.Samples[0].ValueBytes!).ShouldBe(42.0); - - client.Close(); - await serverTask; - } - - /// TLS: a self-signed server cert; the client pins its thumbprint; same exchange succeeds. - [Fact] - public async Task Tls_RoundTrip_HelloAcceptedAndRequestEchoed() - { - using var cts = new CancellationTokenSource(Timeout); - using var cert = MakeSelfSignedCert(); - using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet); - var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); - - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); - using var ssl = await ClientTlsAsync(client.GetStream(), cert.Thumbprint, cts.Token); - - var ack = await HelloAsync(ssl, "shh", cts.Token); - ack.Accepted.ShouldBeTrue(); - - using var writer = new FrameWriter(ssl, leaveOpen: true); - using var reader = new FrameReader(ssl, leaveOpen: true); - - await writer.WriteAsync(MessageKind.ReadRawRequest, - new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "tls-1" }, cts.Token); - - var replyFrame = await reader.ReadFrameAsync(cts.Token); - replyFrame.ShouldNotBeNull(); - replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply); - var reply = MessagePackSerializer.Deserialize(replyFrame.Value.Body); - reply.Success.ShouldBeTrue(); - reply.CorrelationId.ShouldBe("tls-1"); - - client.Close(); - await serverTask; - } - - /// - /// TLS: when the client pins a wrong thumbprint the validation callback returns false, - /// causing to throw - /// before any Hello is exchanged. - /// - [Fact] - public async Task Tls_BadThumbprint_AuthenticationFails() - { - using var cts = new CancellationTokenSource(Timeout); - using var cert = MakeSelfSignedCert(); - using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet); - var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); - - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); - - // Deliberately pin the wrong thumbprint — all zeros. - const string wrongThumbprint = "0000000000000000000000000000000000000000"; - var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false, - userCertificateValidationCallback: (_, serverCert, _, _) => - serverCert is not null && - string.Equals(serverCert.GetCertHashString(), wrongThumbprint, StringComparison.OrdinalIgnoreCase)); - - await Should.ThrowAsync(async () => - await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null, - enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false)); - - ssl.Dispose(); - // Server will see the broken TLS handshake and end the connection; let it finish. - try { await serverTask; } catch { /* server may throw on the aborted TLS */ } - } - - /// Bad secret: Hello is rejected with Accepted=false and the shared-secret-mismatch reason. - [Fact] - public async Task BadSecret_HelloRejected() - { - using var cts = new CancellationTokenSource(Timeout); - using var server = new TcpFrameServer(IPAddress.Loopback, 0, "right-secret", tlsCert: null, Quiet); - var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); - - using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); - - var ack = await HelloAsync(client.GetStream(), "wrong-secret", cts.Token); - ack.Accepted.ShouldBeFalse(); - ack.RejectReason.ShouldBe("shared-secret-mismatch"); - - client.Close(); - await serverTask; - } - - /// - /// Single-active serial accept: while client A is connected (Hello done), client B's - /// Hello does not complete until A disconnects. The server only accepts one connection - /// per , so B's handshake is served by - /// the second loop iteration that runs only after A's connection ends. - /// - [Fact] - public async Task SingleActive_SecondClientHelloCompletesOnlyAfterFirstCloses() - { - using var cts = new CancellationTokenSource(Timeout); - using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet); - - // Run the server loop: it accepts one connection at a time, serially. - var serverLoop = server.RunAsync(new EchoHandler(), cts.Token); - - // Client A connects and completes its Hello — it now owns the single active slot. - using var clientA = new TcpClient(); - await clientA.ConnectAsync(IPAddress.Loopback, server.BoundPort); - var ackA = await HelloAsync(clientA.GetStream(), "shh", cts.Token); - ackA.Accepted.ShouldBeTrue(); - - // Client B connects. The TCP connect may complete (OS backlog) but the server is still - // busy with A, so B's Hello round-trip must NOT complete yet. - using var clientB = new TcpClient(); - await clientB.ConnectAsync(IPAddress.Loopback, server.BoundPort); - var bHelloTask = HelloAsync(clientB.GetStream(), "shh", cts.Token); - - // Give B a chance to (wrongly) complete — it must remain pending while A is connected. - var earlyWinner = await Task.WhenAny(bHelloTask, Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token)); - earlyWinner.ShouldNotBe(bHelloTask, "client B's Hello completed while client A was still connected"); - - // Now disconnect A. The server's next loop iteration accepts B and serves its Hello. - clientA.Close(); - - var ackB = await bHelloTask; - ackB.Accepted.ShouldBeTrue(); - - // Tear down: cancel the loop and let it unwind. - cts.Cancel(); - try { await serverLoop; } catch (OperationCanceledException) { /* expected */ } - } - - [Fact] - public async Task BindFailure_SurfacesBindError_NotPermanentNotListening() - { - // Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a - // Windows excluded range → WSAEACCES, or already in use), the failure must surface as the - // bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a - // permanent InvalidOperationException "Not listening". The latter is the assign-before-Start - // wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts, - // which crash-looped the live sidecar on the reserved port 32569. - using var cts = new CancellationTokenSource(Timeout); - - // Occupy a loopback port exclusively so the server's Start() bind is forbidden. - var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true }; - blocker.Start(); - try - { - var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port; - using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet); - - // First accept attempt: the bind fails with a SocketException. - await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); - - // Second attempt MUST also be the bind SocketException — not InvalidOperationException - // "Not listening". This is the assertion that fails against the assign-before-Start bug. - var second = await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); - second.ShouldBeOfType(); - } - finally { blocker.Stop(); } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs deleted file mode 100644 index 23258a4e..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// PR C.2 — pins the env-var contract that gates whether the sidecar boots an - /// alarm-event writer. Default-on (when the historian itself is enabled) so a - /// fresh deploy picks up the writer without a service-config edit; explicit - /// false opts a read-only deployment out. - /// - [Trait("Category", "Unit")] - public sealed class ProgramAlarmWriterTests - { - /// Verifies that BuildAlarmWriter returns a writer when the environment variable is unset. - [Fact] - public void BuildAlarmWriter_returns_writer_when_env_unset() - { - using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", null); - - var writer = Program.BuildAlarmWriter(); - - writer.ShouldNotBeNull(); - writer.ShouldBeOfType(); - } - - /// Verifies that BuildAlarmWriter returns a writer when the environment variable is explicitly true. - /// The truthy environment variable string value to test. - [Theory] - [InlineData("true")] - [InlineData("True")] - [InlineData("TRUE")] - public void BuildAlarmWriter_returns_writer_when_env_explicitly_true(string value) - { - using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value); - - var writer = Program.BuildAlarmWriter(); - - writer.ShouldNotBeNull(); - } - - /// Verifies that BuildAlarmWriter returns null when the environment variable is false. - /// The falsy environment variable string value to test. - [Theory] - [InlineData("false")] - [InlineData("False")] - [InlineData("FALSE")] - public void BuildAlarmWriter_returns_null_when_env_false(string value) - { - using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value); - - var writer = Program.BuildAlarmWriter(); - - writer.ShouldBeNull(); - } - - /// Verifies that BuildAlarmWriter treats unrecognized values as enabled. - [Fact] - public void BuildAlarmWriter_treats_unrecognized_value_as_enabled() - { - // Anything other than the literal "false" (case-insensitive) keeps the writer - // wired — fail-open under accidental misconfiguration so an alarm-write deploy - // doesn't silently lose alarms because of a typo. - using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", "yes"); - - var writer = Program.BuildAlarmWriter(); - - writer.ShouldNotBeNull(); - } - - private static IDisposable ScopedEnv(string name, string? value) - { - var prior = Environment.GetEnvironmentVariable(name); - Environment.SetEnvironmentVariable(name, value); - return new DisposableAction(() => Environment.SetEnvironmentVariable(name, prior)); - } - - /// Disposable wrapper for an action that executes on disposal. - private sealed class DisposableAction : IDisposable - { - private readonly Action _action; - /// Initializes a new instance that will execute the given action on disposal. - /// The action to execute when disposed. - public DisposableAction(Action action) { _action = action; } - /// Executes the stored action. - public void Dispose() => _action(); - } - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs deleted file mode 100644 index 0ba6059d..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramSmokeTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests; - -/// -/// Smoke test confirming the sidecar project links and the test project resolves a -/// ProjectReference to it. Real behavioural tests live with the TCP frame server -/// (TcpFrameServer); here we just verify the assembly identity is what the -/// csproj declares. -/// -public class ProgramSmokeTests -{ - /// Verifies that the Program assembly has the expected name. - [Fact] - public void Program_Assembly_HasExpectedName() - { - typeof(Program).Assembly.GetName().Name - .ShouldBe("OtOpcUa.Driver.Historian.Wonderware"); - } -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj deleted file mode 100644 index eeb7e994..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net48 - x64 - enable - latest - false - true - ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - ..\..\..\lib\aahClientManaged.dll - false - - - - diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs index 8ede8c2a..6726d347 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs @@ -62,7 +62,7 @@ public sealed class DriverPageJsonConverterTests var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes() .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) .ToList(); - allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-page fleet"); + allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet"); DriverPageTypes.Count.ShouldBe(allDriverPages.Count, "every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs deleted file mode 100644 index df4928cd..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; - -public sealed class HistorianWonderwareDriverPageFormSerializationTests -{ - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false, - }; - - [Fact] - public void RoundTrip_PreservesKnownFields() - { - var original = new WonderwareHistorianClientOptions( - Host: "historian-prod.zb.local", - Port: 32569, - SharedSecret: "t0ps3cr3t", - PeerName: "OtOpcUa-Primary", - ConnectTimeout: TimeSpan.FromSeconds(20), - CallTimeout: TimeSpan.FromSeconds(60)) - { - ProbeTimeoutSeconds = 25, - UseTls = true, - ServerCertThumbprint = "A1B2C3D4E5F60718293A4B5C6D7E8F9012345678", - }; - - var json = JsonSerializer.Serialize(original, _opts); - var back = JsonSerializer.Deserialize(json, _opts); - - back.ShouldNotBeNull(); - back.Host.ShouldBe("historian-prod.zb.local"); - back.Port.ShouldBe(32569); - back.SharedSecret.ShouldBe("t0ps3cr3t"); - back.PeerName.ShouldBe("OtOpcUa-Primary"); - back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); - back.CallTimeout.ShouldBe(TimeSpan.FromSeconds(60)); - back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); - back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(60)); - back.ProbeTimeoutSeconds.ShouldBe(25); - back.UseTls.ShouldBeTrue(); - back.ServerCertThumbprint.ShouldBe("A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"); - } - - [Fact] - public void RoundTrip_NullTimeouts_UsesDefaults() - { - var original = new WonderwareHistorianClientOptions( - Host: "localhost", - Port: 32569, - SharedSecret: "secret"); - - var json = JsonSerializer.Serialize(original, _opts); - var back = JsonSerializer.Deserialize(json, _opts); - - back.ShouldNotBeNull(); - back.ConnectTimeout.ShouldBeNull(); - back.CallTimeout.ShouldBeNull(); - back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(10)); - back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(30)); - back.UseTls.ShouldBeFalse(); - back.ServerCertThumbprint.ShouldBeNull(); - } - - [Fact] - public void Deserialize_DropsUnknownFields() - { - var jsonWithExtra = """ - { - "unknownField": "old-value", - "host": "historian.zb.local", - "port": 32569, - "sharedSecret": "s3cr3t", - "probeTimeoutSeconds": 20 - } - """; - - var optsWithSkip = new JsonSerializerOptions(_opts) - { - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - }; - - var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); - back.ShouldNotBeNull(); - back.ProbeTimeoutSeconds.ShouldBe(20); - back.Host.ShouldBe("historian.zb.local"); - back.Port.ShouldBe(32569); - } - - [Fact] - public void FormModel_RoundTrip_PreservesAllFields() - { - // Construct a record with non-default values for every property and verify - // that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless. - var original = new WonderwareHistorianClientOptions( - Host: "historian-prod.zb.local", - Port: 32570, - SharedSecret: "sup3rs3cr3t", - PeerName: "OtOpcUa-Redundant", - ConnectTimeout: TimeSpan.FromSeconds(18), - CallTimeout: TimeSpan.FromSeconds(45)) - { - ProbeTimeoutSeconds = 30, - UseTls = true, - ServerCertThumbprint = "0011223344556677889AABBCCDDEEFF001122334", - }; - - var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original); - var result = form.ToRecord(); - - result.Host.ShouldBe("historian-prod.zb.local"); - result.Port.ShouldBe(32570); - result.SharedSecret.ShouldBe("sup3rs3cr3t"); - result.PeerName.ShouldBe("OtOpcUa-Redundant"); - result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18)); - result.CallTimeout.ShouldBe(TimeSpan.FromSeconds(45)); - result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18)); - result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45)); - result.ProbeTimeoutSeconds.ShouldBe(30); - result.UseTls.ShouldBeTrue(); - result.ServerCertThumbprint.ShouldBe("0011223344556677889AABBCCDDEEFF001122334"); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/HistorianWonderwareAddressBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/HistorianWonderwareAddressBuilderTests.cs deleted file mode 100644 index 15a463c0..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/HistorianWonderwareAddressBuilderTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers; - -public sealed class HistorianWonderwareAddressBuilderTests -{ - [Theory] - [InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")] - [InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")] - [InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")] - public void Build_Canonical(string tag, string mode, int interval, string expected) - => HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected); - - /// A tag name carrying query-reserved characters is percent-encoded so the produced - /// address stays a well-formed query string (AdminUI-005). With "A&B?C" the '&' and '?' - /// must not be read as a query separator / start, so they are escaped. - [Fact] - public void Build_escapes_reserved_characters_in_tag_name() - { - var result = HistorianWonderwareAddressBuilder.Build("A&B?C", "Cyclic", 60); - - // The only literal '?' is the query separator the builder inserts; the only literal '&' - // is the one between mode and interval. The reserved characters in the name are escaped. - result.ShouldBe("A%26B%3FC?mode=Cyclic&interval=60"); - result.IndexOf('?').ShouldBe(result.IndexOf("?mode=", System.StringComparison.Ordinal)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs deleted file mode 100644 index 2428c501..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HistorianWonderwareTagConfigModelTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; - -namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; - -public sealed class HistorianWonderwareTagConfigModelTests -{ - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("{}")] - public void FromJson_returns_defaults_for_empty_input(string? json) - { - var m = HistorianWonderwareTagConfigModel.FromJson(json); - - m.FullName.ShouldBe(""); - } - - [Fact] - public void FromJson_reads_FullName() - { - var m = HistorianWonderwareTagConfigModel.FromJson( - """{"FullName":"Reactor1.Temp"}"""); - - m.FullName.ShouldBe("Reactor1.Temp"); - } - - [Fact] - public void Round_trip_preserves_FullName() - { - var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }; - - var json = m.ToJson(); - var m2 = HistorianWonderwareTagConfigModel.FromJson(json); - - m2.FullName.ShouldBe("Reactor1.Temp"); - } - - [Fact] - public void ToJson_emits_PascalCase_FullName() - { - var m = new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }; - - var json = m.ToJson(); - - // FullName is the composer/walker contract key — PascalCase, case-sensitive. - json.ShouldContain("\"FullName\":\"Reactor1.Temp\""); - json.ShouldNotContain("\"fullName\"", Case.Sensitive); - } - - [Fact] - public void FromJson_then_ToJson_preserves_unknown_keys() - { - var json = HistorianWonderwareTagConfigModel - .FromJson("""{"FullName":"Reactor1.Temp","deadband":0.5}""") - .ToJson(); - - json.ShouldContain("deadband"); - json.ShouldContain("0.5"); - // and the exposed field still round-trips - json.ShouldContain("\"FullName\":\"Reactor1.Temp\""); - } - - [Fact] - public void FromJson_then_ToJson_preserves_TagModal_merged_history_keys() - { - // The TagModal-merge seam writes isHistorized/historianTagname at the TagConfig root; this model - // does NOT model them, so they must survive a load→save untouched as preserved unknown keys. - var json = HistorianWonderwareTagConfigModel - .FromJson("""{"FullName":"Reactor1.Temp","isHistorized":true,"historianTagname":"Reactor1.Temp.Override"}""") - .ToJson(); - - json.ShouldContain("\"isHistorized\":true"); - json.ShouldContain("\"historianTagname\":\"Reactor1.Temp.Override\""); - json.ShouldContain("\"FullName\":\"Reactor1.Temp\""); - } - - [Fact] - public void ToJson_trims_FullName() - { - var json = new HistorianWonderwareTagConfigModel { FullName = " Reactor1.Temp " }.ToJson(); - - json.ShouldContain("\"FullName\":\"Reactor1.Temp\""); - } - - [Fact] - public void Validate_returns_error_when_FullName_blank() - { - new HistorianWonderwareTagConfigModel().Validate().ShouldNotBeNullOrEmpty(); - new HistorianWonderwareTagConfigModel { FullName = " " }.Validate().ShouldNotBeNullOrEmpty(); - } - - [Fact] - public void Validate_returns_null_when_FullName_present() - { - new HistorianWonderwareTagConfigModel { FullName = "Reactor1.Temp" }.Validate().ShouldBeNull(); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs index 93917cf4..b5f21a45 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigValidatorTests.cs @@ -31,7 +31,6 @@ public sealed class TagConfigValidatorTests [InlineData("TwinCat")] [InlineData("Focas")] [InlineData("OpcUaClient")] - [InlineData("Historian.Wonderware")] public void Required_field_blank_is_rejected(string driverType) { TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty(); @@ -42,10 +41,6 @@ public sealed class TagConfigValidatorTests public void OpcUaClient_with_full_name_is_valid() => TagConfigValidator.Validate("OpcUaClient", """{"FullName":"ns=2;s=Line3.Temp"}""").ShouldBeNull(); - [Fact] - public void HistorianWonderware_with_full_name_is_valid() - => TagConfigValidator.Validate("Historian.Wonderware", """{"FullName":"Reactor1.Temp"}""").ShouldBeNull(); - [Fact] public void S7_with_address_is_valid() => TagConfigValidator.Validate("S7", """{"address":"DB1.DBW0"}""").ShouldBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs index 99ca150d..2d2e7bf0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -29,7 +29,6 @@ public sealed class DriverProbeRegistrationTests "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively "OpcUaClient", "GalaxyMxGateway", - "Historian.Wonderware", ]; [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs index 9e6368db..3421208f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs @@ -112,81 +112,54 @@ public sealed class AlarmHistorianRegistrationTests opts.DeadLetterRetentionDays.ShouldBe(7); } - [Fact] - public void Validate_warns_on_empty_shared_secret_when_enabled() - { - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "/var/h.db" }; - opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); - } - [Fact] public void Validate_warns_on_relative_database_path_when_enabled() { - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "alarm-historian.db" }; + var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db" }; opts.Validate().ShouldContain(w => w.Contains("DatabasePath")); } [Fact] public void Validate_is_silent_when_correctly_configured() { - new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty(); + new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty(); } [Fact] public void Validate_is_silent_when_disabled() { - new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty(); + new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty(); } [Fact] public void Validate_warns_on_non_positive_drain_interval() { - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 }; opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds")); } [Fact] public void Validate_warns_on_non_positive_capacity() { - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", Capacity = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", Capacity = 0 }; opts.Validate().ShouldContain(w => w.Contains("Capacity")); } [Fact] public void Validate_warns_on_non_positive_retention() { - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 }; opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays")); } [Fact] public void Validate_accumulates_multiple_warnings() { - // relative path + empty secret ⇒ both warnings, not short-circuited on the first. - var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "alarm-historian.db" }; + // relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first. + var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 }; var warnings = opts.Validate(); - warnings.ShouldContain(w => w.Contains("SharedSecret")); warnings.ShouldContain(w => w.Contains("DatabasePath")); + warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds")); warnings.Count.ShouldBeGreaterThanOrEqualTo(2); } - - [Fact] - public void Section_binds_tcp_host_port_tls_fields() - { - var config = ConfigFrom(new Dictionary - { - ["AlarmHistorian:Host"] = "historian.example.com", - ["AlarmHistorian:Port"] = "12345", - ["AlarmHistorian:UseTls"] = "true", - ["AlarmHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF", - }); - - var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get(); - - opts.ShouldNotBeNull(); - opts.Host.ShouldBe("historian.example.com"); - opts.Port.ShouldBe(12345); - opts.UseTls.ShouldBeTrue(); - opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF"); - } }