From fe91d42927b3e8cea1d105a5321032fbd87d84a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 08:01:19 -0400 Subject: [PATCH] =?UTF-8?q?PR=207.2=20=E2=80=94=20Retire=20legacy=20Galaxy?= =?UTF-8?q?=20projects=20+=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix-gate satisfied (14 passed / 1 skipped / 0 failed on 2026-04-30 per docs/v2/Galaxy.ParityMatrix.md). Galaxy access flows through the in-process GalaxyDriver → mxaccessgw exclusively. Legacy infrastructure deleted in this commit: Source projects (6): - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (.NET 4.8 x86 + MXAccess COM) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process pipe client) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared (pipe-IPC contracts) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests Test projects with no consumer after legacy retired (3): - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E (drove Galaxy.Host EXE) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests (drove both backends) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport (only consumed by Host/Proxy tests) Edits: - ZB.MOM.WW.OtOpcUa.slnx: drop nine project entries - Server.csproj: drop Driver.Galaxy.Proxy ProjectReference - Server/Program.cs: drop GalaxyProxyDriverFactoryExtensions.Register + the parallel-registration comment block; only GalaxyDriverFactoryExtensions registers now under DriverType "GalaxyMxGateway" - Install-Services.ps1: rewrite to drop OtOpcUaGalaxyHost service install + the GalaxySharedSecret/ZbConnection/GalaxyClientName/GalaxyPipeName/ AvevaServiceDependencies/MxAccessInitialConnect* parameters that only applied to the legacy host. Adds a closing note pointing operators at the separate mxaccessgw install - Uninstall-Services.ps1: keep OtOpcUaGalaxyHost in the cleanup loop so pre-7.2 rigs upgrade-uninstall cleanly, plus add OtOpcUaWonderwareHistorian - scripts/e2e/test-galaxy.ps1: deleted (drove the legacy E2E) - scripts/e2e/e2e-config.sample.json: rewrite the galaxy section comment to reflect the GalaxyMxGateway-only path - scripts/e2e/README.md: drop OtOpcUaGalaxyHost references - scripts/compliance/phase-7-compliance.ps1: drop Galaxy.Shared HistorianAlarms* checks (those contracts moved to Driver.Historian.Wonderware.Client in PR 3.4) Live state: OtOpcUaGalaxyHost Windows service stopped + removed via NSSM before this commit. The dev box's Galaxy access is now exclusively through the running mxaccessgw (separate repo). Stays out of scope for PR 7.2 (PR 7.3 territory): - CLAUDE.md Galaxy section rewrite - mxaccess_documentation.md deletion - Memory entries for the now-retired Galaxy.Host service Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 9 - scripts/compliance/phase-7-compliance.ps1 | 6 +- scripts/e2e/README.md | 6 +- scripts/e2e/e2e-config.sample.json | 2 +- scripts/e2e/test-galaxy.ps1 | 298 --------- scripts/install/Install-Services.ps1 | 148 +++-- scripts/install/Uninstall-Services.ps1 | 11 +- .../Backend/Alarms/GalaxyAlarmTracker.cs | 260 -------- .../Backend/DbBackedGalaxyBackend.cs | 188 ------ .../Backend/Galaxy/GalaxyHierarchyRow.cs | 35 - .../Backend/Galaxy/GalaxyRepository.cs | 224 ------- .../Backend/Galaxy/GalaxyRepositoryOptions.cs | 13 - .../Backend/IGalaxyBackend.cs | 46 -- .../Backend/MxAccess/IMxProxy.cs | 43 -- .../Backend/MxAccess/MxAccessClient.cs | 408 ------------ .../Backend/MxAccess/MxProxyAdapter.cs | 68 -- .../SubscriptionReplayFailedEventArgs.cs | 20 - .../Backend/MxAccess/Vtq.cs | 24 - .../Backend/MxAccessGalaxyBackend.cs | 608 ------------------ .../Stability/GalaxyRuntimeProbeManager.cs | 273 -------- .../Backend/StubGalaxyBackend.cs | 121 ---- .../Ipc/GalaxyFrameHandler.cs | 183 ------ .../Ipc/PipeAcl.cs | 45 -- .../Ipc/PipeServer.cs | 179 ------ .../Ipc/StubFrameHandler.cs | 33 - .../IsExternalInit.cs | 5 - .../Program.cs | 139 ---- .../Sta/MxAccessHandle.cs | 58 -- .../Sta/StaPump.cs | 206 ------ .../Stability/MemoryWatchdog.cs | 64 -- .../Stability/PostMortemMmf.cs | 121 ---- .../Stability/RecyclePolicy.cs | 40 -- ...B.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj | 53 -- .../GalaxyProxyDriver.cs | 590 ----------------- .../GalaxyProxyDriverFactoryExtensions.cs | 61 -- .../Ipc/GalaxyHistorianWriter.cs | 90 --- .../Ipc/GalaxyIpcClient.cs | 243 ------- .../Supervisor/Backoff.cs | 29 - .../Supervisor/CircuitBreaker.cs | 68 -- .../Supervisor/HeartbeatMonitor.cs | 28 - ....MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj | 30 - .../Contracts/Alarms.cs | 32 - .../Contracts/DataValues.cs | 53 -- .../Contracts/Discovery.cs | 50 -- .../Contracts/Framing.cs | 75 --- .../Contracts/Hello.cs | 36 -- .../Contracts/HistorianAlarms.cs | 92 --- .../Contracts/History.cs | 110 ---- .../Contracts/Lifecycle.cs | 47 -- .../Contracts/Probe.cs | 34 - .../Contracts/Subscriptions.cs | 34 - .../FrameReader.cs | 67 -- .../FrameWriter.cs | 57 -- ...MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj | 23 - src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 11 +- .../ZB.MOM.WW.OtOpcUa.Server.csproj | 1 - .../HierarchyParityTests.cs | 58 -- .../ParityFixture.cs | 127 ---- .../RecordingAddressSpaceBuilder.cs | 70 -- .../StabilityFindingsRegressionTests.cs | 140 ---- ...ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj | 36 -- .../AlarmDiscoveryTests.cs | 84 --- .../AvevaPrerequisitesLiveTests.cs | 127 ---- .../EndToEndIpcTests.cs | 170 ----- .../GalaxyAlarmTrackerTests.cs | 190 ------ .../GalaxyRepositoryLiveSmokeTests.cs | 111 ---- .../GalaxyRuntimeProbeManagerTests.cs | 231 ------- .../HistorianWiringTests.cs | 109 ---- .../HistoryReadAtTimeTests.cs | 147 ----- .../HistoryReadEventsTests.cs | 129 ---- .../HistoryReadProcessedTests.cs | 158 ----- .../HostStatusPushTests.cs | 91 --- .../IpcHandshakeIntegrationTests.cs | 108 ---- .../MemoryWatchdogTests.cs | 64 -- .../MxAccessClientMonitorLoopTests.cs | 173 ----- .../MxAccessLiveSmokeTests.cs | 116 ---- .../PostMortemMmfTests.cs | 64 -- .../RecyclePolicyTests.cs | 51 -- .../StaPumpTests.cs | 47 -- ...WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj | 40 -- .../AlarmTransitionParityTests.cs | 103 --- .../BrowseAndReadParityTests.cs | 118 ---- .../HarnessShapeTests.cs | 36 -- .../HistoryReadParityTests.cs | 69 -- .../ParityHarness.cs | 290 --------- .../ReconnectParityTests.cs | 69 -- .../RecordingAddressSpaceBuilder.cs | 59 -- .../ScanStateProbeParityTests.cs | 100 --- .../SoakScenarioTests.cs | 138 ---- .../SubscribeAndEventRateParityTests.cs | 105 --- .../WriteByClassificationParityTests.cs | 92 --- ...W.OtOpcUa.Driver.Galaxy.ParityTests.csproj | 39 -- .../AggregateColumnMappingTests.cs | 27 - .../BackoffTests.cs | 28 - .../CircuitBreakerTests.cs | 78 --- .../GalaxyHistorianWriterMappingTests.cs | 83 --- .../GalaxyIpcClientRoutingTests.cs | 209 ------ .../HeartbeatMonitorTests.cs | 40 -- .../HistoricalEventMappingTests.cs | 81 --- .../HostSubprocessParityTests.cs | 123 ---- .../LiveStack/LiveStackConfig.cs | 75 --- .../LiveStack/LiveStackFixture.cs | 119 ---- .../LiveStack/LiveStackSmokeTests.cs | 282 -------- ...W.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj | 33 - .../ContractRoundTripTests.cs | 68 -- .../FramingTests.cs | 74 --- ....OtOpcUa.Driver.Galaxy.Shared.Tests.csproj | 31 - .../AvevaPrerequisites.cs | 163 ----- .../Net48Polyfills.cs | 26 - .../PrerequisiteCheck.cs | 44 -- .../PrerequisiteReport.cs | 94 --- .../Probes/MxAccessComProbe.cs | 102 --- .../Probes/NamedPipeProbe.cs | 59 -- .../Probes/RegistryProbe.cs | 162 ----- .../Probes/ServiceProbe.cs | 85 --- .../Probes/SqlProbe.cs | 88 --- ...W.OtOpcUa.Driver.Galaxy.TestSupport.csproj | 38 -- 117 files changed, 115 insertions(+), 11754 deletions(-) delete mode 100644 scripts/e2e/test-galaxy.ps1 delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Stability/GalaxyRuntimeProbeManager.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/HierarchyParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/RecordingAddressSpaceBuilder.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/EndToEndIpcTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRuntimeProbeManagerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianWiringTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/IpcHandshakeIntegrationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessClientMonitorLoopTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessLiveSmokeTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/AlarmTransitionParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ReconnectParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ScanStateProbeParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SoakScenarioTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyHistorianWriterMappingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyIpcClientRoutingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HostSubprocessParityTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 6231325..e578810 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -9,9 +9,6 @@ - - - @@ -46,12 +43,6 @@ - - - - - - diff --git a/scripts/compliance/phase-7-compliance.ps1 b/scripts/compliance/phase-7-compliance.ps1 index e0b0847..e78fe30 100644 --- a/scripts/compliance/phase-7-compliance.ps1 +++ b/scripts/compliance/phase-7-compliance.ps1 @@ -73,13 +73,13 @@ Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAl Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs") Write-Host "" -Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)" +Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward; alarm-event sidecar IPC moved to Driver.Historian.Wonderware.Client in PR 3.4)" Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj" Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs") Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs") Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs") -Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs") -Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs") +# Galaxy.Shared pipe-IPC contracts retired in PR 7.2 alongside the rest of the legacy +# Galaxy projects. Wonderware sidecar contracts live in Driver.Historian.Wonderware.Client. Write-Host "" Write-Host "Stream E - Config DB schema" diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 316f04b..96cf909 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -63,7 +63,9 @@ live driver. The factory-wiring block that originally gated stages Live-boot verification: - **Galaxy** — 7/7 stages (read / write / subscribe / alarms / history) - against a real Galaxy + `OtOpcUaGalaxyHost` on this dev box. + against a real Galaxy via the in-process `GalaxyDriver` → + `mxaccessgw` (gRPC). PR 7.2 retired the legacy `OtOpcUaGalaxyHost` + out-of-process driver path. - **AB CIP, S7** — 5/5 stages each under task #220 against the `ab_server` + `python-snap7` fixtures. - **AB Legacy** — 5/5 stages under task #222 against `ab_server` SLC500 @@ -155,7 +157,7 @@ section to skip it. | Modbus | — | **PASS** (pymodbus fixture) | | AB CIP | — | **PASS** (ab_server fixture) | | AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) | -| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) | +| Galaxy | — | **PASS** (requires mxaccessgw running + a live Galaxy; 7 stages including alarms + history; PR 7.2 retired the legacy OtOpcUaGalaxyHost path) | | S7 | — | **PASS** (python-snap7 fixture) | | FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) | | TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** by default; features **validated** against the TCBSD VM fixture — set the env var to run | diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json index c8dbc64..05377b4 100644 --- a/scripts/e2e/e2e-config.sample.json +++ b/scripts/e2e/e2e-config.sample.json @@ -50,7 +50,7 @@ }, "galaxy": { - "$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. PR 7.1 default-flipped backend to GalaxyMxGateway (in-process .NET 10 driver over mxaccessgw gRPC at http://localhost:5120 by default — override via the DriverInstance row's DriverConfig). Pre-flip rigs running the legacy 'Galaxy' DriverType still need OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.", + "$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. The driver is now the in-process GalaxyDriver (DriverType = 'GalaxyMxGateway') talking gRPC to a separately-installed mxaccessgw at http://localhost:5120 by default — override via the DriverInstance row's DriverConfig. PR 7.2 retired the legacy 'Galaxy' DriverType + OtOpcUaGalaxyHost service.", "sourceNodeId": "ns=2;s=p7-smoke-tag-source", "virtualNodeId": "ns=2;s=p7-smoke-vt-derived", "alarmNodeId": "ns=2;s=p7-smoke-al-overtemp", diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1 deleted file mode 100644 index dc0e3e4..0000000 --- a/scripts/e2e/test-galaxy.ps1 +++ /dev/null @@ -1,298 +0,0 @@ -#Requires -Version 7.0 -<# -.SYNOPSIS - End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe, - alarms, and history through a running OtOpcUa server. - -.DESCRIPTION - Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy - driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost` - over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process). - Every stage therefore goes through `otopcua-cli` against the published OPC UA - address space. - - Seven stages: - - 1. Probe — otopcua-cli connect + read the source NodeId; confirms - the whole Galaxy.Host → Proxy → server → client chain is - up - 2. Source read — otopcua-cli read returns a Good value for the source - attribute; proves IReadable.ReadAsync is dispatching - through the IPC bridge - 3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms - the Phase 7 CachedTagUpstreamSource is bridging the - driver-sourced input into the scripting engine - 4. Subscribe-sees-change — subscribe to the source NodeId in the background; - Galaxy pushes a data-change event within N seconds - (Galaxy's underlying attribute must be actively - changing — production Galaxies typically have - scan-driven updates; for idle galaxies, widen - -ChangeWaitSec or drive the write stage below first) - 5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute; - read it back. Gracefully becomes INFO-only if the - attribute's Galaxy-side AccessLevel forbids writes - (BadUserAccessDenied / BadNotWritable) - 6. Alarm fires — subscribe to the scripted-alarm Condition NodeId, - drive the source tag above its threshold, confirm an - Active alarm event surfaces. Exercises the Part 9 - alarm-condition propagation path - 7. History read — historyread on the source tag over the last hour; - confirms Aveva Historian → IHistoryProvider dispatch - returns samples - - The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the - right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag - (source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy - attribute FullName into `dbo.Tag.TagConfig` before running. - -.PARAMETER OpcUaUrl - OtOpcUa server endpoint. Default opc.tcp://localhost:4840. - -.PARAMETER SourceNodeId - NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). NodeIds - are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed - walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` → - `reactor-1` → `Source` (Tag.Name). - -.PARAMETER VirtualNodeId - NodeId of the VirtualTag that computes MachineStatus = (Source > 0) (Phase 7 - scripting). Same path-based scheme, ending in the VirtualTag.Name - (`MachineStatus`). The tag is historized so the write/subscribe exercise - doubles as a historian-sink check. - -.PARAMETER AlarmNodeId - NodeId of the scripted-alarm Condition (fires when Source > 50). Same - path-based scheme, ending in ScriptedAlarm.Name (`OverTemp`). - -.PARAMETER AlarmTriggerValue - Value written to -SourceNodeId to push it over the alarm threshold. - Default 75 (well above the seeded 50-threshold). - -.PARAMETER ChangeWaitSec - Seconds the subscribe-sees-change stage waits for a natural data change. - Default 10. Idle galaxies may need this extended or the stage will fail - with "subscribe did not observe...". - -.PARAMETER AlarmWaitSec - Seconds the alarm-fires stage waits after triggering the write. Default 10. - -.PARAMETER HistoryLookbackSec - Seconds back from now to query history. Default 3600 (1 h). - -.EXAMPLE - # Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server - ./scripts/e2e/test-galaxy.ps1 - -.EXAMPLE - # Custom NodeIds from a non-smoke cluster - ./scripts/e2e/test-galaxy.ps1 ` - -SourceNodeId "ns=2;s=Reactor1.Temperature" ` - -VirtualNodeId "ns=2;s=Reactor1.TempDoubled" ` - -AlarmNodeId "ns=2;s=Reactor1.OverTemp" ` - -AlarmTriggerValue 120 -#> - -param( - [string]$OpcUaUrl = "opc.tcp://localhost:4840", - [string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source", - [string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/MachineStatus", - [string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp", - [string]$AlarmTriggerValue = "75", - [int]$ChangeWaitSec = 10, - [int]$AlarmWaitSec = 10, - [int]$HistoryLookbackSec = 3600, - # The default Phase 7 seed uses a Galaxy attribute with - # security_classification=Operate. Anonymous OPC UA sessions are denied writes - # against Operate-classified tags (PR 26 / docs/Security.md). Supply an LDAP - # user with WriteOperate to exercise the reverse-bridge stage — e.g. - # `-Username writeop -Password writeop123` against the dev-box GLAuth. - [string]$Username = "", - [string]$Password = "" -) - -$ErrorActionPreference = "Stop" -. "$PSScriptRoot/_common.ps1" - -$opcUaCli = Get-CliInvocation ` - -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` - -ExeName "otopcua-cli" - -# Auth-extension helper — appends `-U / -P` to the CLI args when credentials -# were supplied. Stays empty for anonymous runs so the default smoke path -# doesn't require an LDAP round-trip. -$authArgs = @() -if ($Username) { $authArgs += @("-U", $Username) } -if ($Password) { $authArgs += @("-P", $Password) } - -$results = @() - -# --------------------------------------------------------------------------- -# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId; -# success implies Galaxy.Host is up + the pipe ACL lets the server connect + -# the Proxy is tracking the tag + the server published it. -# --------------------------------------------------------------------------- - -Write-Header "Probe" -$probe = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) -if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") { - Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)" - $results += @{ Passed = $true } -} else { - Write-Fail "probe read failed (exit=$($probe.ExitCode))" - Write-Host $probe.Output - $results += @{ Passed = $false; Reason = "probe failed" } -} - -# --------------------------------------------------------------------------- -# Stage 2 — Source read. Captures the current value for the later virtual-tag -# comparison + confirms read dispatch works end-to-end. Failure here without a -# stage-1 failure would be unusual — probe already reads. -# --------------------------------------------------------------------------- - -Write-Header "Source read" -$sourceRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) -$sourceValue = $null -if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") { - $sourceValue = $Matches[1].Trim() - Write-Pass "source value = $sourceValue" - $results += @{ Passed = $true } -} else { - Write-Fail "source read failed" - Write-Host $sourceRead.Output - $results += @{ Passed = $false; Reason = "source read failed" } -} - -# --------------------------------------------------------------------------- -# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not -# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge -# (the seam most likely to silently stop working after a Galaxy-side change). -# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters). -# --------------------------------------------------------------------------- - -if ([string]::IsNullOrEmpty($VirtualNodeId)) { - Write-Header "Virtual-tag bridge" - Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check" -} else { - Write-Header "Virtual-tag bridge" - $vtRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + $authArgs) - if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") { - $vtValue = $Matches[1].Trim() - Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)" - $results += @{ Passed = $true } - } else { - Write-Fail "virtual-tag read failed" - Write-Host $vtRead.Output - $results += @{ Passed = $false; Reason = "virtual-tag read failed" } - } -} - -# --------------------------------------------------------------------------- -# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background; -# wait N seconds for Galaxy to push any data-change event on the source node. -# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec. -# --------------------------------------------------------------------------- - -Write-Header "Subscribe sees change" -$stdout = New-TemporaryFile -$stderr = New-TemporaryFile -$subArgs = @($opcUaCli.PrefixArgs) + @( - "subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId, - "-i", "500", "--duration", "$ChangeWaitSec") + $authArgs -$subProc = Start-Process -FilePath $opcUaCli.File ` - -ArgumentList $subArgs -NoNewWindow -PassThru ` - -RedirectStandardOutput $stdout.FullName ` - -RedirectStandardError $stderr.FullName -Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s" -$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null -if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force } -$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw) -Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue - -# Any `=` followed by `(Good)` line after the initial subscribe-confirmation -# indicates at least one data-change tick arrived. The `@(...)` forces an array -# so `.Count` works on the 0-match + single-match cases that Set-StrictMode -# -Version 3.0 otherwise flags as `property 'Count' cannot be found`. -$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }) -if ($changeLines.Count -gt 0) { - Write-Pass "$($changeLines.Count) data-change events observed" - $results += @{ Passed = $true } -} else { - Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first" - Write-Host $subOut - $results += @{ Passed = $false; Reason = "no data-change" } -} - -# --------------------------------------------------------------------------- -# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with -# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when -# that's the case rather than failing the whole script. -# --------------------------------------------------------------------------- - -Write-Header "Reverse bridge (OPC UA write)" -$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write -$w = Invoke-Cli -Cli $opcUaCli -Args (@( - "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") + $authArgs) -if ($w.ExitCode -ne 0) { - # Connection/protocol failure — still a test failure. - Write-Fail "write CLI exit=$($w.ExitCode)" - Write-Host $w.Output - $results += @{ Passed = $false; Reason = "write failed" } -} elseif ($w.Output -match "Write failed:\s*0x801F0000") { - Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute." - $results += @{ Passed = $true; Reason = "acl-expected" } -} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") { - Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)." - $results += @{ Passed = $true; Reason = "readonly-expected" } -} elseif ($w.Output -match "Write successful") { - # Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle. - Start-Sleep -Seconds 2 - $r = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs) - if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") { - Write-Pass "write propagated — source reads back $writeValue" - $results += @{ Passed = $true } - } else { - Write-Fail "write reported success but read-back did not reflect $writeValue" - Write-Host $r.Output - $results += @{ Passed = $false; Reason = "write-readback mismatch" } - } -} else { - Write-Fail "unexpected write response" - Write-Host $w.Output - $results += @{ Passed = $false; Reason = "unexpected write response" } -} - -# --------------------------------------------------------------------------- -# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already -# wrote the trigger value the alarm may already be active; that's fine — the -# Part 9 ConditionRefresh in the alarms CLI replays the current state so the -# subscribe window still captures the Active event. -# --------------------------------------------------------------------------- - -if ([string]::IsNullOrEmpty($AlarmNodeId)) { - Write-Header "Alarm fires on threshold" - Write-Skip "AlarmNodeId not supplied — skipping alarm check" -} else { - $results += Test-AlarmFiresOnThreshold ` - -OpcUaCli $opcUaCli ` - -OpcUaUrl $OpcUaUrl ` - -AlarmNodeId $AlarmNodeId ` - -InputNodeId $SourceNodeId ` - -TriggerValue $AlarmTriggerValue ` - -DurationSec $AlarmWaitSec -} - -# --------------------------------------------------------------------------- -# Stage 7 — History read. historyread against the source tag over the last N -# seconds. Failure modes the skip pattern catches: tag not historized in the -# Galaxy attribute's historization profile, or the lookback window misses the -# sample cadence. -# --------------------------------------------------------------------------- - -$results += Test-HistoryHasSamples ` - -OpcUaCli $opcUaCli ` - -OpcUaUrl $OpcUaUrl ` - -NodeId $SourceNodeId ` - -LookbackSec $HistoryLookbackSec - -Write-Summary -Title "Galaxy e2e" -Results $results -if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/install/Install-Services.ps1 b/scripts/install/Install-Services.ps1 index d0bceca..d411ba0 100644 --- a/scripts/install/Install-Services.ps1 +++ b/scripts/install/Install-Services.ps1 @@ -1,39 +1,52 @@ <# .SYNOPSIS - Registers the two v2 Windows services on a node: OtOpcUa (main server, net10) and - OtOpcUaGalaxyHost (out-of-process Galaxy COM host, net48 x86). + Registers the v2 Windows services on a node: OtOpcUa (main server, net10) and + optionally OtOpcUaWonderwareHistorian (Wonderware historian sidecar). .DESCRIPTION - Phase 2 Stream D.2 — replaces the v1 single-service install (TopShelf-based OtOpcUa.Host). - Installs both services with the correct service-account SID + per-process shared secret - provisioning per `driver-stability.md §"IPC Security"`. Galaxy.Host depends on OtOpcUa - (Galaxy.Host must be reachable when OtOpcUa starts; service dependency wiring + retry - handled by OtOpcUa.Server NodeBootstrap). + PR 7.2 retired the legacy out-of-process OtOpcUaGalaxyHost service alongside the + GalaxyProxyDriver / GalaxyHost / GalaxyShared projects. Galaxy access now flows + through the in-process GalaxyDriver talking gRPC to a separately-installed + mxaccessgw. The mxaccessgw server runs out of its own repo + (`c:\Users\dohertj2\Desktop\mxaccessgw\`) — see + `docs/v2/Galaxy.ParityRig.md` for the gw setup recipe. .PARAMETER InstallRoot Where the binaries live (typically C:\Program Files\OtOpcUa). .PARAMETER ServiceAccount - Service account SID or DOMAIN\name. Both services run under this account; the - Galaxy.Host pipe ACL only allows this SID to connect (decision #76). + Service account SID or DOMAIN\name. The OtOpcUa service runs under this account. -.PARAMETER GalaxySharedSecret - Per-process secret passed to Galaxy.Host via env var. Generated freshly per install. +.PARAMETER InstallWonderwareHistorian + Gate the OtOpcUaWonderwareHistorian sidecar install. Off by default; set when + the deployment uses the Wonderware historian for history reads + alarm-event + persistence. -.PARAMETER ZbConnection - Galaxy ZB SQL connection string (passed to Galaxy.Host via env var). +.PARAMETER HistorianSharedSecret + Per-process secret passed to the Historian sidecar via env var. Generated + freshly per install when not supplied. .EXAMPLE .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua' + +.EXAMPLE + .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua' ` + -InstallWonderwareHistorian #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$ServiceAccount, - [string]$GalaxySharedSecret, - [string]$ZbConnection = 'Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;', - [string]$GalaxyClientName = 'OtOpcUa-Galaxy.Host', - [string]$GalaxyPipeName = 'OtOpcUaGalaxy' + + # PR 3.W — Wonderware historian sidecar. Optional; gates the + # OtOpcUaWonderwareHistorian service. Secret + pipe defaults match the server's + # Historian:Wonderware appsettings block. + [switch]$InstallWonderwareHistorian, + [string]$HistorianSharedSecret, + [string]$HistorianPipeName = 'OtOpcUaWonderwareHistorian', + [string]$HistorianServer = 'localhost', + [int]$HistorianPort = 32568, + [string[]]$AvevaServiceDependencies = @('NmxSvc', 'aaBootstrap', 'aaGR') ) $ErrorActionPreference = 'Stop' @@ -42,17 +55,18 @@ if (-not (Test-Path "$InstallRoot\OtOpcUa.Server.exe")) { Write-Error "OtOpcUa.Server.exe not found at $InstallRoot — copy the publish output first" exit 1 } -if (-not (Test-Path "$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe")) { - Write-Error "OtOpcUa.Driver.Galaxy.Host.exe not found at $InstallRoot\Galaxy — copy the publish output first" - exit 1 -} -# Generate a fresh shared secret per install if not supplied. Stored in DPAPI-protected file -# rather than the registry so the service account can read it but other local users cannot. -if (-not $GalaxySharedSecret) { +# Generate fresh shared secrets per install if not supplied. +function New-SharedSecret { $bytes = New-Object byte[] 32 [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) - $GalaxySharedSecret = [Convert]::ToBase64String($bytes) + return [Convert]::ToBase64String($bytes) +} +if ($InstallWonderwareHistorian -and -not $HistorianSharedSecret) { $HistorianSharedSecret = New-SharedSecret } + +if ($InstallWonderwareHistorian -and -not (Test-Path "$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe")) { + Write-Error "OtOpcUa.Driver.Historian.Wonderware.exe not found at $InstallRoot\WonderwareHistorian — copy the publish output first" + exit 1 } # Resolve the SID — the IPC ACL needs the SID, not the down-level name. @@ -62,41 +76,67 @@ $sid = if ($ServiceAccount.StartsWith('S-1-')) { (New-Object System.Security.Principal.NTAccount $ServiceAccount).Translate([System.Security.Principal.SecurityIdentifier]).Value } -# --- Install OtOpcUaGalaxyHost first (OtOpcUa starts after, depends on it being up). -$galaxyEnv = @( - "OTOPCUA_GALAXY_PIPE=$GalaxyPipeName" - "OTOPCUA_ALLOWED_SID=$sid" - "OTOPCUA_GALAXY_SECRET=$GalaxySharedSecret" - "OTOPCUA_GALAXY_BACKEND=mxaccess" - "OTOPCUA_GALAXY_ZB_CONN=$ZbConnection" - "OTOPCUA_GALAXY_CLIENT_NAME=$GalaxyClientName" -) -join "`0" -$galaxyEnv += "`0`0" +# --- Install OtOpcUaWonderwareHistorian (PR 3.W) — separate sidecar that exposes the +# Wonderware Historian SDK via a named-pipe protocol consumed by the .NET 10 server. +# Optional: only installed when -InstallWonderwareHistorian is supplied. Depends on the +# hard AVEVA services that host the historian SDK runtime path. +$historianDepend = $null +if ($InstallWonderwareHistorian) { + $historianEnv = @( + "OTOPCUA_HISTORIAN_PIPE=$HistorianPipeName" + "OTOPCUA_ALLOWED_SID=$sid" + "OTOPCUA_HISTORIAN_SECRET=$HistorianSharedSecret" + "OTOPCUA_HISTORIAN_ENABLED=true" + "OTOPCUA_HISTORIAN_SERVER=$HistorianServer" + "OTOPCUA_HISTORIAN_PORT=$HistorianPort" + ) -join "`0" + $historianEnv += "`0`0" -Write-Host "Installing OtOpcUaGalaxyHost..." -& sc.exe create OtOpcUaGalaxyHost binPath= "`"$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe`"" ` - DisplayName= 'OtOpcUa Galaxy Host (out-of-process MXAccess)' ` - start= auto ` - obj= $ServiceAccount | Out-Null + Write-Host "Installing OtOpcUaWonderwareHistorian..." + & sc.exe create OtOpcUaWonderwareHistorian binPath= "`"$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe`"" ` + DisplayName= 'OtOpcUa Wonderware Historian Sidecar (out-of-process aahClient)' ` + start= auto ` + depend= ($AvevaServiceDependencies -join '/') ` + obj= $ServiceAccount | Out-Null + & sc.exe config OtOpcUaWonderwareHistorian start= delayed-auto | Out-Null -# Set per-service environment variables via the registry — sc.exe doesn't expose them directly. -$svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost" -$envValue = $galaxyEnv.Split("`0") | Where-Object { $_ -ne '' } -Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue + $svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaWonderwareHistorian" + $envValue = $historianEnv.Split("`0") | Where-Object { $_ -ne '' } + Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue + + $historianDepend = 'OtOpcUaWonderwareHistorian' +} + +# --- Install OtOpcUa. Galaxy access flows through GalaxyDriver → mxaccessgw (gRPC), +# so OtOpcUa no longer depends on a sibling service for Galaxy connectivity. The +# mxaccessgw is installed separately. When the Wonderware sidecar is installed, +# depend on it for startup ordering. +$otOpcUaDepends = @() +if ($historianDepend) { $otOpcUaDepends += $historianDepend } -# --- Install OtOpcUa (depends on Galaxy host being installed; doesn't strictly require it -# started — OtOpcUa.Server NodeBootstrap retries on the IPC connect path). Write-Host "Installing OtOpcUa..." -& sc.exe create OtOpcUa binPath= "`"$InstallRoot\OtOpcUa.Server.exe`"" ` - DisplayName= 'OtOpcUa Server' ` - start= auto ` - depend= 'OtOpcUaGalaxyHost' ` - obj= $ServiceAccount | Out-Null +$createArgs = @( + 'create', 'OtOpcUa', + 'binPath=', "`"$InstallRoot\OtOpcUa.Server.exe`"", + 'DisplayName=', 'OtOpcUa Server', + 'start=', 'auto', + 'obj=', $ServiceAccount +) +if ($otOpcUaDepends.Count -gt 0) { + $createArgs += @('depend=', ($otOpcUaDepends -join '/')) +} +& sc.exe @createArgs | Out-Null Write-Host "" Write-Host "Installed. Start with:" -Write-Host " sc.exe start OtOpcUaGalaxyHost" +if ($InstallWonderwareHistorian) { Write-Host " sc.exe start OtOpcUaWonderwareHistorian" } Write-Host " sc.exe start OtOpcUa" +if ($InstallWonderwareHistorian) { + Write-Host "" + Write-Host "Wonderware historian shared secret (configure into appsettings.json Historian:Wonderware:SharedSecret):" + Write-Host " $HistorianSharedSecret" +} Write-Host "" -Write-Host "Galaxy shared secret (record this offline — required for service rebinding):" -Write-Host " $GalaxySharedSecret" +Write-Host "NOTE: Galaxy access flows through mxaccessgw — install + run that separately" +Write-Host " per docs/v2/Galaxy.ParityRig.md. OtOpcUa connects via the Galaxy.Gateway" +Write-Host " section of appsettings.json (default endpoint http://localhost:5120)." diff --git a/scripts/install/Uninstall-Services.ps1 b/scripts/install/Uninstall-Services.ps1 index c811226..f5c8206 100644 --- a/scripts/install/Uninstall-Services.ps1 +++ b/scripts/install/Uninstall-Services.ps1 @@ -1,11 +1,18 @@ <# .SYNOPSIS - Stops + removes the two v2 services. Mirrors Install-Services.ps1. + Stops + removes the v2 services. Mirrors Install-Services.ps1. + +.DESCRIPTION + PR 7.2 retired the legacy OtOpcUaGalaxyHost service. Galaxy access now flows + through the in-process GalaxyDriver against a separately-installed mxaccessgw. + OtOpcUaGalaxyHost is included in the cleanup loop below so this script safely + removes it from any rig still carrying the legacy service from a pre-7.2 + install. #> [CmdletBinding()] param() $ErrorActionPreference = 'Continue' -foreach ($svc in 'OtOpcUa', 'OtOpcUaGalaxyHost') { +foreach ($svc in 'OtOpcUa', 'OtOpcUaWonderwareHistorian', 'OtOpcUaGalaxyHost') { if (Get-Service $svc -ErrorAction SilentlyContinue) { Write-Host "Stopping $svc..." Stop-Service $svc -Force -ErrorAction SilentlyContinue diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs deleted file mode 100644 index ea8ec19..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; - -/// -/// Subscribes to the four Galaxy alarm attributes (.InAlarm, .Priority, -/// .DescAttrName, .Acked) per alarm-bearing attribute discovered during -/// DiscoverAsync. Maintains one per alarm, raises -/// on lifecycle transitions (Active / Unacknowledged / -/// Acknowledged / Inactive). Ack path writes .AckMsg. Pure-logic state machine -/// with delegate-based subscribe/write so it's testable against in-memory fakes. -/// -/// -/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model): -/// -/// Active — InAlarm false → true. Default to Unacknowledged. -/// Acknowledged — Acked false → true while InAlarm is still true. -/// Inactive — InAlarm true → false. If still unacknowledged the alarm -/// is marked latched-inactive-unack; next Ack transitions straight to Inactive. -/// -/// -public sealed class GalaxyAlarmTracker : IDisposable -{ - public const string InAlarmAttr = ".InAlarm"; - public const string PriorityAttr = ".Priority"; - public const string DescAttrNameAttr = ".DescAttrName"; - public const string AckedAttr = ".Acked"; - public const string AckMsgAttr = ".AckMsg"; - - private readonly Func, Task> _subscribe; - private readonly Func _unsubscribe; - private readonly Func> _write; - private readonly Func _clock; - - // Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state. - private readonly ConcurrentDictionary _alarms = - new(StringComparer.OrdinalIgnoreCase); - - // Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag. - private readonly ConcurrentDictionary _probeToAlarm = - new(StringComparer.OrdinalIgnoreCase); - - private bool _disposed; - - public event EventHandler? TransitionRaised; - - public GalaxyAlarmTracker( - Func, Task> subscribe, - Func unsubscribe, - Func> write) - : this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { } - - internal GalaxyAlarmTracker( - Func, Task> subscribe, - Func unsubscribe, - Func> write, - Func clock) - { - _subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe)); - _unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe)); - _write = write ?? throw new ArgumentNullException(nameof(write)); - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - } - - public int TrackedAlarmCount => _alarms.Count; - - /// - /// Advise the four alarm attributes for . Idempotent — - /// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the - /// four rolls back the alarm entry so a stale callback cannot promote a phantom. - /// - public async Task TrackAsync(string alarmTag) - { - if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return; - if (_alarms.ContainsKey(alarmTag)) return; - - var state = new AlarmState { AlarmTag = alarmTag }; - if (!_alarms.TryAdd(alarmTag, state)) return; - - var probes = new[] - { - (Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm), - (Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority), - (Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName), - (Tag: alarmTag + AckedAttr, Field: AlarmField.Acked), - }; - - foreach (var p in probes) - { - _probeToAlarm[p.Tag] = (alarmTag, p.Field); - } - - try - { - foreach (var p in probes) - { - await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false); - } - } - catch - { - // Rollback so a partial advise doesn't leak state. - _alarms.TryRemove(alarmTag, out _); - foreach (var p in probes) - { - _probeToAlarm.TryRemove(p.Tag, out _); - try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { } - } - throw; - } - } - - /// - /// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort. - /// - public async Task ClearAsync() - { - _alarms.Clear(); - foreach (var kv in _probeToAlarm.ToList()) - { - _probeToAlarm.TryRemove(kv.Key, out _); - try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { } - } - } - - /// - /// Operator ack — write the comment text into <alarmTag>.AckMsg. - /// Returns false when the runtime reports the write failed. - /// - public Task AcknowledgeAsync(string alarmTag, string comment) - { - if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) - return Task.FromResult(false); - return _write(alarmTag + AckMsgAttr, comment ?? string.Empty); - } - - /// - /// Subscription callback entry point. Exposed for tests and for the Backend to route - /// fan-out callbacks through. Runs the state machine and fires TransitionRaised - /// outside the lock. - /// - public void OnProbeCallback(string probeTag, Vtq vtq) - { - if (_disposed) return; - if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return; - if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return; - - AlarmTransition? transition = null; - var now = _clock(); - - lock (state.Lock) - { - switch (link.Field) - { - case AlarmField.InAlarm: - { - var wasActive = state.InAlarm; - var isActive = vtq.Value is bool b && b; - state.InAlarm = isActive; - state.LastUpdateUtc = now; - if (!wasActive && isActive) - { - state.Acked = false; - state.LastTransitionUtc = now; - transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now); - } - else if (wasActive && !isActive) - { - state.LastTransitionUtc = now; - transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now); - } - break; - } - case AlarmField.Priority: - if (vtq.Value is int pi) state.Priority = pi; - else if (vtq.Value is short ps) state.Priority = ps; - else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl; - state.LastUpdateUtc = now; - break; - case AlarmField.DescAttrName: - state.DescAttrName = vtq.Value as string; - state.LastUpdateUtc = now; - break; - case AlarmField.Acked: - { - var wasAcked = state.Acked; - var isAcked = vtq.Value is bool b && b; - state.Acked = isAcked; - state.LastUpdateUtc = now; - // Fire Acknowledged only when transitioning false→true. Don't fire on initial - // subscribe callback (wasAcked==isAcked in that case because the state starts - // with Acked=false and the initial probe is usually true for an un-active alarm). - if (!wasAcked && isAcked && state.InAlarm) - { - state.LastTransitionUtc = now; - transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now); - } - break; - } - } - } - - if (transition is { } t) - { - TransitionRaised?.Invoke(this, t); - } - } - - public IReadOnlyList SnapshotStates() - { - return _alarms.Values.Select(s => - { - lock (s.Lock) - return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName); - }).ToList(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _alarms.Clear(); - _probeToAlarm.Clear(); - } - - private sealed class AlarmState - { - public readonly object Lock = new(); - public string AlarmTag = ""; - public bool InAlarm; - public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire - public int Priority; - public string? DescAttrName; - public DateTime LastUpdateUtc; - public DateTime LastTransitionUtc; - } - - private enum AlarmField { InAlarm, Priority, DescAttrName, Acked } -} - -public enum AlarmStateTransition { Active, Acknowledged, Inactive } - -public sealed record AlarmTransition( - string AlarmTag, - AlarmStateTransition Transition, - int Priority, - string? DescAttrName, - DateTime AtUtc); - -public sealed record AlarmSnapshot( - string AlarmTag, - bool InAlarm, - bool Acked, - int Priority, - string? DescAttrName); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs deleted file mode 100644 index 63300be..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; - -/// -/// Galaxy backend that uses the live ZB repository for — -/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess -/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface -/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value -/// intermediate state because Discover is what powers the OPC UA address-space build, so -/// downstream Proxy + parity tests can exercise the complete tree shape today. -/// -public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend -{ - private long _nextSessionId; - private long _nextSubscriptionId; - - // DB-only backend doesn't have a runtime data plane; never raises events. -#pragma warning disable CS0067 - public event System.EventHandler? OnDataChange; - public event System.EventHandler? OnAlarmEvent; - public event System.EventHandler? OnHostStatusChanged; -#pragma warning restore CS0067 - - public Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct) - { - var id = Interlocked.Increment(ref _nextSessionId); - return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id }); - } - - public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask; - - public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct) - { - try - { - var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false); - var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false); - - // Group attributes by their owning gobject for the IPC payload. - var attrsByGobject = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray()); - - var parentByChild = hierarchy - .ToDictionary(o => o.GobjectId, o => o.ParentGobjectId); - var nameByGobject = hierarchy - .ToDictionary(o => o.GobjectId, o => o.TagName); - - var objects = hierarchy.Select(o => new GalaxyObjectInfo - { - ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName, - TagName = o.TagName, - ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p) - && p != 0 - && nameByGobject.TryGetValue(p, out var pName) - ? pName - : null, - TemplateCategory = MapCategory(o.CategoryId), - Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty(), - }).ToArray(); - - return new DiscoverHierarchyResponse { Success = true, Objects = objects }; - } - catch (Exception ex) when (ex is System.Data.SqlClient.SqlException - or InvalidOperationException - or TimeoutException) - { - return new DiscoverHierarchyResponse - { - Success = false, - Error = $"Galaxy ZB repository error: {ex.Message}", - Objects = System.Array.Empty(), - }; - } - } - - public Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct) - => Task.FromResult(new ReadValuesResponse - { - Success = false, - Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only", - Values = System.Array.Empty(), - }); - - public Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct) - { - var results = new WriteValueResult[req.Writes.Length]; - for (var i = 0; i < req.Writes.Length; i++) - { - results[i] = new WriteValueResult - { - TagReference = req.Writes[i].TagReference, - StatusCode = 0x80020000u, - Error = "MXAccess code lift pending (Phase 2 Task B.1)", - }; - } - return Task.FromResult(new WriteValuesResponse { Results = results }); - } - - public Task SubscribeAsync(SubscribeRequest req, CancellationToken ct) - { - var sid = Interlocked.Increment(ref _nextSubscriptionId); - return Task.FromResult(new SubscribeResponse - { - Success = true, - SubscriptionId = sid, - ActualIntervalMs = req.RequestedIntervalMs, - }); - } - - public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask; - public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask; - public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask; - - public Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadResponse - { - Success = false, - Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", - Tags = System.Array.Empty(), - }); - - public Task HistoryReadProcessedAsync( - HistoryReadProcessedRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadProcessedResponse - { - Success = false, - Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", - Values = System.Array.Empty(), - }); - - public Task HistoryReadAtTimeAsync( - HistoryReadAtTimeRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadAtTimeResponse - { - Success = false, - Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", - Values = System.Array.Empty(), - }); - - public Task HistoryReadEventsAsync( - HistoryReadEventsRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadEventsResponse - { - Success = false, - Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", - Events = System.Array.Empty(), - }); - - public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) - => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); - - private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new() - { - AttributeName = row.AttributeName, - MxDataType = row.MxDataType, - IsArray = row.IsArray, - ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null, - SecurityClassification = row.SecurityClassification, - IsHistorized = row.IsHistorized, - IsAlarm = row.IsAlarm, - }; - - /// - /// Galaxy template_definition.category_id → human-readable name. - /// Mirrors v1 Host's AlarmObjectFilter mapping. - /// - private static string MapCategory(int categoryId) => categoryId switch - { - 1 => "$WinPlatform", - 3 => "$AppEngine", - 4 => "$Area", - 10 => "$UserDefined", - 11 => "$ApplicationObject", - 13 => "$Area", - 17 => "$DeviceIntegration", - 24 => "$ViewEngine", - 26 => "$ViewApp", - _ => $"category-{categoryId}", - }; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs deleted file mode 100644 index 8f0ede4..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; - -/// -/// One row from the v1 HierarchySql. Galaxy gobject deployed instance with its -/// hierarchy parent + template-chain context. -/// -public sealed class GalaxyHierarchyRow -{ - public int GobjectId { get; init; } - public string TagName { get; init; } = string.Empty; - public string ContainedName { get; init; } = string.Empty; - public string BrowseName { get; init; } = string.Empty; - public int ParentGobjectId { get; init; } - public bool IsArea { get; init; } - public int CategoryId { get; init; } - public int HostedByGobjectId { get; init; } - public System.Collections.Generic.IReadOnlyList TemplateChain { get; init; } = System.Array.Empty(); -} - -/// One row from the v1 AttributesSql. -public sealed class GalaxyAttributeRow -{ - public int GobjectId { get; init; } - public string TagName { get; init; } = string.Empty; - public string AttributeName { get; init; } = string.Empty; - public string FullTagReference { get; init; } = string.Empty; - public int MxDataType { get; init; } - public string? DataTypeName { get; init; } - public bool IsArray { get; init; } - public int? ArrayDimension { get; init; } - public int MxAttributeCategory { get; init; } - public int SecurityClassification { get; init; } - public bool IsHistorized { get; init; } - public bool IsAlarm { get; init; } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs deleted file mode 100644 index 2d511be..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.SqlClient; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; - -/// -/// SQL access to the Galaxy ZB repository — port of v1 GalaxyRepositoryService. -/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the -/// queries surface the same row set at parity time. Extended-attributes and scope-filter -/// queries from v1 are intentionally not ported yet — they're refinements that aren't on -/// the Phase 2 critical path. -/// -public sealed class GalaxyRepository(GalaxyRepositoryOptions options) -{ - public async Task TestConnectionAsync(CancellationToken ct = default) - { - try - { - using var conn = new SqlConnection(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds }; - var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - return result is int i && i == 1; - } - catch (SqlException) { return false; } - catch (InvalidOperationException) { return false; } - } - - public async Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - using var conn = new SqlConnection(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn) - { CommandTimeout = options.CommandTimeoutSeconds }; - var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); - return result is DateTime dt ? dt : null; - } - - public async Task> GetHierarchyAsync(CancellationToken ct = default) - { - var rows = new List(); - - using var conn = new SqlConnection(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - - using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8); - var templateChain = templateChainRaw.Length == 0 - ? Array.Empty() - : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => s.Length > 0) - .ToArray(); - - rows.Add(new GalaxyHierarchyRow - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), - BrowseName = reader.GetString(3), - ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), - IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, - CategoryId = Convert.ToInt32(reader.GetValue(6)), - HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)), - TemplateChain = templateChain, - }); - } - return rows; - } - - public async Task> GetAttributesAsync(CancellationToken ct = default) - { - var rows = new List(); - - using var conn = new SqlConnection(options.ConnectionString); - await conn.OpenAsync(ct).ConfigureAwait(false); - - using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); - - while (await reader.ReadAsync(ct).ConfigureAwait(false)) - { - rows.Add(new GalaxyAttributeRow - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - AttributeName = reader.GetString(2), - FullTagReference = reader.GetString(3), - MxDataType = Convert.ToInt32(reader.GetValue(4)), - DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5), - IsArray = Convert.ToInt32(reader.GetValue(6)) == 1, - ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)), - MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)), - SecurityClassification = Convert.ToInt32(reader.GetValue(9)), - IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1, - }); - } - return rows; - } - - private const string HierarchySql = @" -;WITH template_chain AS ( - SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, - t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth - FROM gobject g - INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id - WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 - UNION ALL - SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1 - FROM template_chain tc - INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id - WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 -) -SELECT DISTINCT - g.gobject_id, - g.tag_name, - g.contained_name, - CASE WHEN g.contained_name IS NULL OR g.contained_name = '' - THEN g.tag_name - ELSE g.contained_name - END AS browse_name, - CASE WHEN g.contained_by_gobject_id = 0 - THEN g.area_gobject_id - ELSE g.contained_by_gobject_id - END AS parent_gobject_id, - CASE WHEN td.category_id = 13 - THEN 1 - ELSE 0 - END AS is_area, - td.category_id AS category_id, - g.hosted_by_gobject_id AS hosted_by_gobject_id, - ISNULL( - STUFF(( - SELECT '|' + tc.template_tag_name - FROM template_chain tc - WHERE tc.instance_gobject_id = g.gobject_id - ORDER BY tc.depth - FOR XML PATH('') - ), 1, 1, ''), - '' - ) AS template_chain -FROM gobject g -INNER JOIN template_definition td - ON g.template_definition_id = td.template_definition_id -WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND g.is_template = 0 - AND g.deployed_package_id <> 0 -ORDER BY parent_gobject_id, g.tag_name"; - - private const string AttributesSql = @" -;WITH deployed_package_chain AS ( - SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth - FROM gobject g - INNER JOIN package p ON p.package_id = g.deployed_package_id - WHERE g.is_template = 0 AND g.deployed_package_id <> 0 - UNION ALL - SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 - FROM deployed_package_chain dpc - INNER JOIN package p ON p.package_id = dpc.derived_from_package_id - WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 -) -SELECT gobject_id, tag_name, attribute_name, full_tag_reference, - mx_data_type, data_type_name, is_array, array_dimension, - mx_attribute_category, security_classification, is_historized, is_alarm -FROM ( - SELECT - dpc.gobject_id, - g.tag_name, - da.attribute_name, - g.tag_name + '.' + da.attribute_name - + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END - AS full_tag_reference, - da.mx_data_type, - dt.description AS data_type_name, - da.is_array, - CASE WHEN da.is_array = 1 - THEN CONVERT(int, CONVERT(varbinary(2), - SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) - ELSE NULL - END AS array_dimension, - da.mx_attribute_category, - da.security_classification, - CASE WHEN EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' - WHERE dpc2.gobject_id = dpc.gobject_id - ) THEN 1 ELSE 0 END AS is_historized, - CASE WHEN EXISTS ( - SELECT 1 FROM deployed_package_chain dpc2 - INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name - INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' - WHERE dpc2.gobject_id = dpc.gobject_id - ) THEN 1 ELSE 0 END AS is_alarm, - ROW_NUMBER() OVER ( - PARTITION BY dpc.gobject_id, da.attribute_name - ORDER BY dpc.depth - ) AS rn - FROM deployed_package_chain dpc - INNER JOIN dynamic_attribute da - ON da.package_id = dpc.package_id - INNER JOIN gobject g - ON g.gobject_id = dpc.gobject_id - INNER JOIN template_definition td - ON td.template_definition_id = g.template_definition_id - LEFT JOIN data_type dt - ON dt.mx_data_type = da.mx_data_type - WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) - AND da.attribute_name NOT LIKE '[_]%' - AND da.attribute_name NOT LIKE '%.Description' - AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) -) ranked -WHERE rn = 1 -ORDER BY tag_name, attribute_name"; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs deleted file mode 100644 index b72a759..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; - -/// -/// Connection settings for the Galaxy ZB repository database. Set from the -/// DriverConfig JSON section Database per plan.md §"Galaxy DriverConfig". -/// -public sealed class GalaxyRepositoryOptions -{ - public string ConnectionString { get; init; } = - "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; - - public int CommandTimeoutSeconds { get; init; } = 60; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs deleted file mode 100644 index 5f7329b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; - -/// -/// Galaxy data-plane abstraction. Replaces the placeholder StubFrameHandler with a -/// real boundary the lifted MxAccessClient + GalaxyRepository implement during -/// Phase 2 Task B.1. Splitting the IPC dispatch (GalaxyFrameHandler) from the -/// backend means the dispatcher is unit-testable against an in-memory mock without needing -/// live Galaxy. -/// -public interface IGalaxyBackend -{ - /// - /// Server-pushed events the backend raises asynchronously (data-change, alarm, - /// host-status). The frame handler subscribes once on connect and forwards each - /// event to the Proxy as a typed notification. - /// - event System.EventHandler? OnDataChange; - event System.EventHandler? OnAlarmEvent; - event System.EventHandler? OnHostStatusChanged; - - Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct); - Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct); - - Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct); - - Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct); - Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct); - - Task SubscribeAsync(SubscribeRequest req, CancellationToken ct); - Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct); - - Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct); - Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct); - - Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct); - Task HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct); - Task HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct); - Task HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct); - - Task RecycleAsync(RecycleHostRequest req, CancellationToken ct); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs deleted file mode 100644 index 5ab9e72..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs +++ /dev/null @@ -1,43 +0,0 @@ -using ArchestrA.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -/// -/// Delegate matching LMXProxyServer.OnDataChange COM event signature. Allows -/// to subscribe via the abstracted -/// instead of the COM object directly (so the test mock works without MXAccess registered). -/// -public delegate void MxDataChangeHandler( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] ItemStatus); - -public delegate void MxWriteCompleteHandler( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] ItemStatus); - -/// -/// Abstraction over LMXProxyServer — port of v1 IMxProxy. Same surface area -/// so the lifted client behaves identically; only the namespace + apartment-marshalling -/// entry-point change. -/// -public interface IMxProxy -{ - int Register(string clientName); - void Unregister(int handle); - - int AddItem(int handle, string address); - void RemoveItem(int handle, int itemHandle); - - void AdviseSupervisory(int handle, int itemHandle); - void UnAdviseSupervisory(int handle, int itemHandle); - - void Write(int handle, int itemHandle, object value, int securityClassification); - - event MxDataChangeHandler? OnDataChange; - event MxWriteCompleteHandler? OnWriteComplete; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs deleted file mode 100644 index 6fd9bd0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs +++ /dev/null @@ -1,408 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -/// -/// MXAccess runtime client — focused port of v1 MxAccessClient. Owns one -/// LMXProxyServer COM connection on the supplied ; serializes -/// read / write / subscribe through the pump because all COM calls must run on the STA -/// thread. Subscriptions are stored so they can be replayed on reconnect (full reconnect -/// loop is the deferred-but-non-blocking refinement; this version covers connect/read/write -/// /subscribe/unsubscribe — the MVP needed for parity testing). -/// -public sealed class MxAccessClient : IDisposable -{ - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly StaPump _pump; - private readonly IMxProxy _proxy; - private readonly string _clientName; - private readonly MxAccessClientOptions _options; - - // Galaxy attribute reference → MXAccess item handle (set on first Subscribe/Read). - private readonly ConcurrentDictionary _addressToHandle = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _handleToAddress = new(); - private readonly ConcurrentDictionary> _subscriptions = - new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _pendingWrites = new(); - - private int _connectionHandle; - private bool _connected; - private DateTime _lastObservedActivityUtc = DateTime.UtcNow; - private CancellationTokenSource? _monitorCts; - private int _reconnectCount; - private bool _disposed; - - /// Fires whenever the connection transitions Connected ↔ Disconnected. - public event EventHandler? ConnectionStateChanged; - - /// - /// Fires once per failed subscription replay after a reconnect. Carries the tag reference - /// and the exception so the backend can propagate the degradation signal (e.g. mark the - /// subscription bad on the Proxy side rather than silently losing its callback). Added for - /// PR 6 low finding #2 — the replay loop previously ate per-tag failures silently and an - /// operator would only find out that a specific subscription stopped updating through a - /// data-quality complaint from downstream. - /// - public event EventHandler? SubscriptionReplayFailed; - - public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName, MxAccessClientOptions? options = null) - { - _pump = pump; - _proxy = proxy; - _clientName = clientName; - _options = options ?? new MxAccessClientOptions(); - _proxy.OnDataChange += OnDataChange; - _proxy.OnWriteComplete += OnWriteComplete; - } - - public bool IsConnected => _connected; - public int SubscriptionCount => _subscriptions.Count; - public int ReconnectCount => _reconnectCount; - - /// - /// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so - /// can tag its OnHostStatusChanged IPC - /// pushes with a stable gateway name per PR 8. - /// - public string ClientName => _clientName; - - /// Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call. - public async Task ConnectAsync() - { - var handle = await _pump.InvokeAsync(() => - { - if (_connected) return _connectionHandle; - _connectionHandle = _proxy.Register(_clientName); - _connected = true; - return _connectionHandle; - }); - - ConnectionStateChanged?.Invoke(this, true); - - if (_options.AutoReconnect && _monitorCts is null) - { - _monitorCts = new CancellationTokenSource(); - _ = Task.Run(() => MonitorLoopAsync(_monitorCts.Token)); - } - - return handle; - } - - public async Task DisconnectAsync() - { - _monitorCts?.Cancel(); - _monitorCts = null; - - await _pump.InvokeAsync(() => - { - if (!_connected) return; - try { _proxy.Unregister(_connectionHandle); } - finally - { - _connected = false; - _addressToHandle.Clear(); - _handleToAddress.Clear(); - } - }); - - ConnectionStateChanged?.Invoke(this, false); - } - - /// - /// Background loop that watches for connection liveness signals and triggers - /// reconnect-with-replay when the connection appears dead. Per Phase 2 high finding #2: - /// v1's MxAccessClient.Monitor pattern lifted into the new pump-based client. Uses - /// observed-activity timestamp + optional probe-tag subscription. Without an explicit - /// probe tag, falls back to "no data change in N seconds + no successful read in N - /// seconds = unhealthy" — same shape as v1. - /// - private async Task MonitorLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try { await Task.Delay(_options.MonitorInterval, ct); } - catch (OperationCanceledException) { break; } - - if (!_connected || _disposed) continue; - - var idle = DateTime.UtcNow - _lastObservedActivityUtc; - if (idle <= _options.StaleThreshold) continue; - - // Probe: try a no-op COM call. If the proxy is dead, the call will throw — that's - // our reconnect signal. PR 6 low finding #1: AddItem allocates an MXAccess item - // handle; we must RemoveItem it on the same pump turn or the long-running monitor - // leaks one handle per probe cycle (one every MonitorInterval seconds, indefinitely). - bool probeOk; - try - { - probeOk = await _pump.InvokeAsync(() => - { - int probeHandle = 0; - try - { - probeHandle = _proxy.AddItem(_connectionHandle, "$Heartbeat"); - return probeHandle > 0; - } - catch { return false; } - finally - { - if (probeHandle > 0) - { - try { _proxy.RemoveItem(_connectionHandle, probeHandle); } - catch { /* proxy is dying; best-effort cleanup */ } - } - } - }); - } - catch { probeOk = false; } - - if (probeOk) - { - _lastObservedActivityUtc = DateTime.UtcNow; - continue; - } - - // Connection appears dead — reconnect-with-replay. - try - { - await _pump.InvokeAsync(() => - { - try { _proxy.Unregister(_connectionHandle); } catch { /* dead anyway */ } - _connected = false; - }); - ConnectionStateChanged?.Invoke(this, false); - - await _pump.InvokeAsync(() => - { - _connectionHandle = _proxy.Register(_clientName); - _connected = true; - }); - _reconnectCount++; - ConnectionStateChanged?.Invoke(this, true); - - // Replay every subscription that was active before the disconnect. PR 6 low - // finding #2: surface per-tag failures — log them and raise - // SubscriptionReplayFailed so the backend can propagate the degraded state - // (previously swallowed silently; downstream quality dropped without a signal). - var snapshot = _addressToHandle.Keys.ToArray(); - _addressToHandle.Clear(); - _handleToAddress.Clear(); - var failed = 0; - foreach (var fullRef in snapshot) - { - try { await SubscribeOnPumpAsync(fullRef); } - catch (Exception subEx) - { - failed++; - Log.Warning(subEx, - "MXAccess subscription replay failed for {TagReference} after reconnect #{Reconnect}", - fullRef, _reconnectCount); - SubscriptionReplayFailed?.Invoke(this, - new SubscriptionReplayFailedEventArgs(fullRef, subEx)); - } - } - - if (failed > 0) - Log.Warning("Subscription replay completed — {Failed} of {Total} failed", failed, snapshot.Length); - else - Log.Information("Subscription replay completed — {Total} re-subscribed cleanly", snapshot.Length); - - _lastObservedActivityUtc = DateTime.UtcNow; - } - catch - { - // Reconnect failed; back off and retry on the next tick. - _connected = false; - } - } - } - - /// - /// One-shot read implemented as a transient subscribe + unsubscribe. - /// LMXProxyServer doesn't expose a synchronous read, so the canonical pattern - /// (lifted from v1) is to subscribe, await the first OnDataChange, then unsubscribe. - /// This method captures that single value. - /// - public async Task ReadAsync(string fullReference, TimeSpan timeout, CancellationToken ct) - { - if (!_connected) throw new InvalidOperationException("MxAccessClient not connected"); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Action oneShot = (_, value) => tcs.TrySetResult(value); - - // Stash the one-shot handler before sending the subscribe, then remove it after firing. - _subscriptions.AddOrUpdate(fullReference, oneShot, (_, existing) => Combine(existing, oneShot)); - var addedToReadOnlyAttribute = !_addressToHandle.ContainsKey(fullReference); - - try - { - await SubscribeOnPumpAsync(fullReference); - - using var _ = ct.Register(() => tcs.TrySetCanceled()); - var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(timeout, ct)); - if (raceTask != tcs.Task) throw new TimeoutException($"MXAccess read of {fullReference} timed out after {timeout}"); - - return await tcs.Task; - } - finally - { - // High 1 — always detach the one-shot handler, even on cancellation/timeout/throw. - // If we were the one who added the underlying MXAccess subscription (no other - // caller had it), tear it down too so we don't leak a probe item handle. - _subscriptions.AddOrUpdate(fullReference, _ => default!, (_, existing) => Remove(existing, oneShot)); - if (addedToReadOnlyAttribute) - { - try { await UnsubscribeAsync(fullReference); } - catch { /* shutdown-best-effort */ } - } - } - } - - /// - /// Writes to the runtime and AWAITS the OnWriteComplete - /// callback so the caller learns the actual write status. Per Phase 2 medium finding #4 - /// in exit-gate-phase-2.md: the previous fire-and-forget version returned a - /// false-positive Good even when the runtime rejected the write post-callback. - /// - public async Task WriteAsync(string fullReference, object value, - int securityClassification = 0, TimeSpan? timeout = null) - { - if (!_connected) throw new InvalidOperationException("MxAccessClient not connected"); - var actualTimeout = timeout ?? TimeSpan.FromSeconds(5); - - var itemHandle = await _pump.InvokeAsync(() => ResolveItem(fullReference)); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pendingWrites.TryAdd(itemHandle, tcs)) - { - // A prior write to the same item handle is still pending — uncommon but possible - // if the caller spammed writes. Replace it: the older TCS observes a Cancelled task. - if (_pendingWrites.TryRemove(itemHandle, out var prior)) - prior.TrySetCanceled(); - _pendingWrites[itemHandle] = tcs; - } - - try - { - await _pump.InvokeAsync(() => - _proxy.Write(_connectionHandle, itemHandle, value, securityClassification)); - - var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(actualTimeout)); - if (raceTask != tcs.Task) - throw new TimeoutException($"MXAccess write of {fullReference} timed out after {actualTimeout}"); - - return await tcs.Task; - } - finally - { - _pendingWrites.TryRemove(itemHandle, out _); - } - } - - public async Task SubscribeAsync(string fullReference, Action callback) - { - if (!_connected) throw new InvalidOperationException("MxAccessClient not connected"); - - _subscriptions.AddOrUpdate(fullReference, callback, (_, existing) => Combine(existing, callback)); - await SubscribeOnPumpAsync(fullReference); - } - - public Task UnsubscribeAsync(string fullReference) => _pump.InvokeAsync(() => - { - if (!_connected) return; - if (!_addressToHandle.TryRemove(fullReference, out var handle)) return; - _handleToAddress.TryRemove(handle, out _); - _subscriptions.TryRemove(fullReference, out _); - - try - { - _proxy.UnAdviseSupervisory(_connectionHandle, handle); - _proxy.RemoveItem(_connectionHandle, handle); - } - catch { /* best-effort during teardown */ } - }); - - private Task SubscribeOnPumpAsync(string fullReference) => _pump.InvokeAsync(() => - { - if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing; - - var itemHandle = _proxy.AddItem(_connectionHandle, fullReference); - _addressToHandle[fullReference] = itemHandle; - _handleToAddress[itemHandle] = fullReference; - _proxy.AdviseSupervisory(_connectionHandle, itemHandle); - return itemHandle; - }); - - private int ResolveItem(string fullReference) - { - if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing; - var itemHandle = _proxy.AddItem(_connectionHandle, fullReference); - _addressToHandle[fullReference] = itemHandle; - _handleToAddress[itemHandle] = fullReference; - return itemHandle; - } - - private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue, - int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] itemStatus) - { - if (!_handleToAddress.TryGetValue(phItemHandle, out var fullRef)) return; - - // Liveness: any data-change event is proof the connection is alive. - _lastObservedActivityUtc = DateTime.UtcNow; - - var ts = pftItemTimeStamp is DateTime dt ? dt.ToUniversalTime() : DateTime.UtcNow; - var quality = (byte)Math.Min(255, Math.Max(0, pwItemQuality)); - var vtq = new Vtq(pvItemValue, ts, quality); - - if (_subscriptions.TryGetValue(fullRef, out var cb)) cb?.Invoke(fullRef, vtq); - } - - private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] itemStatus) - { - if (_pendingWrites.TryRemove(phItemHandle, out var tcs)) - tcs.TrySetResult(itemStatus is null || itemStatus.Length == 0 || itemStatus[0].success != 0); - } - - private static Action Combine(Action a, Action b) - => (Action)Delegate.Combine(a, b)!; - - private static Action Remove(Action source, Action remove) - => (Action?)Delegate.Remove(source, remove) ?? ((_, _) => { }); - - public void Dispose() - { - _disposed = true; - _monitorCts?.Cancel(); - - try { DisconnectAsync().GetAwaiter().GetResult(); } - catch { /* swallow */ } - - _proxy.OnDataChange -= OnDataChange; - _proxy.OnWriteComplete -= OnWriteComplete; - _monitorCts?.Dispose(); - } -} - -/// -/// Tunables for 's reconnect monitor. Defaults match the v1 -/// monitor's polling cadence so behavior is consistent across the lift. -/// -public sealed class MxAccessClientOptions -{ - /// Whether to start the background monitor at connect time. - public bool AutoReconnect { get; init; } = true; - - /// How often the monitor wakes up to check liveness. - public TimeSpan MonitorInterval { get; init; } = TimeSpan.FromSeconds(5); - - /// If no data-change activity in this window, the monitor probes the connection. - public TimeSpan StaleThreshold { get; init; } = TimeSpan.FromSeconds(60); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs deleted file mode 100644 index b16ef86..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using ArchestrA.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -/// -/// Concrete backed by a real LMXProxyServer COM object. -/// Port of v1 MxProxyAdapter. Must only be constructed on an STA thread -/// — the StaPump owns this instance. -/// -public sealed class MxProxyAdapter : IMxProxy, IDisposable -{ - private LMXProxyServer? _lmxProxy; - - public event MxDataChangeHandler? OnDataChange; - public event MxWriteCompleteHandler? OnWriteComplete; - - public int Register(string clientName) - { - _lmxProxy = new LMXProxyServer(); - _lmxProxy.OnDataChange += ProxyOnDataChange; - _lmxProxy.OnWriteComplete += ProxyOnWriteComplete; - - var handle = _lmxProxy.Register(clientName); - if (handle <= 0) - throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}"); - return handle; - } - - public void Unregister(int handle) - { - if (_lmxProxy is null) return; - try - { - _lmxProxy.OnDataChange -= ProxyOnDataChange; - _lmxProxy.OnWriteComplete -= ProxyOnWriteComplete; - _lmxProxy.Unregister(handle); - } - finally - { - // ReleaseComObject loop until refcount = 0 — the Tier C SafeHandle wraps this in - // production; here the lifetime is owned by the surrounding MxAccessHandle. - while (Marshal.IsComObject(_lmxProxy) && Marshal.ReleaseComObject(_lmxProxy) > 0) { } - _lmxProxy = null; - } - } - - public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address); - - public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle); - - public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle); - - public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle); - - public void Write(int handle, int itemHandle, object value, int securityClassification) => - _lmxProxy!.Write(handle, itemHandle, value, securityClassification); - - private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue, - int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus) - => OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus); - - private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus) - => OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus); - - public void Dispose() => Unregister(0); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs deleted file mode 100644 index ee8f03b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -/// -/// Fired by when a previously-active -/// subscription fails to be restored after a reconnect. The backend should treat the tag as -/// unhealthy until the next successful resubscribe. -/// -public sealed class SubscriptionReplayFailedEventArgs : EventArgs -{ - public SubscriptionReplayFailedEventArgs(string tagReference, Exception exception) - { - TagReference = tagReference; - Exception = exception; - } - - public string TagReference { get; } - public Exception Exception { get; } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs deleted file mode 100644 index 45ac067..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -/// Value-timestamp-quality triplet — port of v1 Vtq. -public readonly struct Vtq -{ - public object? Value { get; } - public DateTime TimestampUtc { get; } - public byte Quality { get; } - - public Vtq(object? value, DateTime timestampUtc, byte quality) - { - Value = value; - TimestampUtc = timestampUtc; - Quality = quality; - } - - /// OPC DA Good = 192. - public static Vtq Good(object? v) => new(v, DateTime.UtcNow, 192); - - /// OPC DA Bad = 0. - public static Vtq Bad() => new(null, DateTime.UtcNow, 0); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs deleted file mode 100644 index ff92a55..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ /dev/null @@ -1,608 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; - -/// -/// Production — combines the SQL-backed -/// for Discover with the live MXAccess -/// for Read / Write / Subscribe. History stays bad-coded -/// until the Wonderware Historian SDK plugin loader (Task B.1.h) lands. Alarms come from -/// MxAccess AlarmExtension primitives but the wire-up is also Phase 2 follow-up -/// (the v1 alarm subsystem is its own subtree). -/// -public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable -{ - private readonly GalaxyRepository _repository; - private readonly MxAccessClient _mx; - private readonly IHistorianDataSource? _historian; - private long _nextSessionId; - private long _nextSubscriptionId; - - // Active SubscriptionId → MXAccess full reference list — so Unsubscribe can find them. - private readonly System.Collections.Concurrent.ConcurrentDictionary> _subs = new(); - // Reverse lookup: tag reference → subscription IDs subscribed to it (one tag may belong to many). - private readonly System.Collections.Concurrent.ConcurrentDictionary> - _refToSubs = new(System.StringComparer.OrdinalIgnoreCase); - - public event System.EventHandler? OnDataChange; - public event System.EventHandler? OnAlarmEvent; - public event System.EventHandler? OnHostStatusChanged; - - private readonly System.EventHandler _onConnectionStateChanged; - private readonly GalaxyRuntimeProbeManager _probeManager; - private readonly System.EventHandler _onProbeStateChanged; - private readonly GalaxyAlarmTracker _alarmTracker; - private readonly System.EventHandler _onAlarmTransition; - - // Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise. - // One entry per IsAlarm=true attribute in the last discovered hierarchy. - private readonly System.Collections.Concurrent.ConcurrentBag _discoveredAlarmTags = new(); - - public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null) - { - _repository = repository; - _mx = mx; - _historian = historian; - - // PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions - // connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named - // after the Wonderware client identity so the Admin UI surfaces top-level transport - // health even before per-platform/per-engine probing lands (deferred to a later PR that - // ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions). - _onConnectionStateChanged = (_, connected) => - { - OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus - { - HostName = _mx.ClientName, - RuntimeStatus = connected ? "Running" : "Stopped", - LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }); - }; - _mx.ConnectionStateChanged += _onConnectionStateChanged; - - // PR 13: per-platform runtime probes. ScanState subscriptions fire OnProbeCallback, - // which runs the state machine and raises StateChanged on transitions we care about. - // We forward each transition through the same OnHostStatusChanged IPC event that the - // gateway-level ConnectionStateChanged uses — tagged with the platform's TagName so the - // Admin UI can show per-host health independently from the top-level transport status. - _probeManager = new GalaxyRuntimeProbeManager( - subscribe: (probe, cb) => _mx.SubscribeAsync(probe, cb), - unsubscribe: probe => _mx.UnsubscribeAsync(probe)); - _onProbeStateChanged = (_, t) => - { - OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus - { - HostName = t.TagName, - RuntimeStatus = t.NewState switch - { - HostRuntimeState.Running => "Running", - HostRuntimeState.Stopped => "Stopped", - _ => "Unknown", - }, - LastObservedUtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - }); - }; - _probeManager.StateChanged += _onProbeStateChanged; - - // PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four - // alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle, - // and raise GalaxyAlarmEvent on transitions — forwarded through the existing - // OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames. - _alarmTracker = new GalaxyAlarmTracker( - subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb), - unsubscribe: tag => _mx.UnsubscribeAsync(tag), - write: (tag, v) => _mx.WriteAsync(tag, v)); - _onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent - { - EventId = Guid.NewGuid().ToString("N"), - ObjectTagName = t.AlarmTag, - AlarmName = t.AlarmTag, - Severity = t.Priority, - StateTransition = t.Transition switch - { - AlarmStateTransition.Active => "Active", - AlarmStateTransition.Acknowledged => "Acknowledged", - AlarmStateTransition.Inactive => "Inactive", - _ => "Unknown", - }, - Message = t.DescAttrName ?? t.AlarmTag, - UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - }); - _alarmTracker.TransitionRaised += _onAlarmTransition; - } - - /// - /// Exposed for tests. Production flow: DiscoverAsync completes → backend calls - /// SyncProbesAsync with the runtime hosts (WinPlatform + AppEngine gobjects) to - /// advise ScanState per host. - /// - internal GalaxyRuntimeProbeManager ProbeManager => _probeManager; - - public async Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct) - { - try - { - await _mx.ConnectAsync(); - return new OpenSessionResponse { Success = true, SessionId = Interlocked.Increment(ref _nextSessionId) }; - } - catch (Exception ex) - { - return new OpenSessionResponse { Success = false, Error = $"MXAccess connect failed: {ex.Message}" }; - } - } - - public async Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) - { - await _mx.DisconnectAsync(); - } - - public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct) - { - try - { - var hierarchy = await _repository.GetHierarchyAsync(ct).ConfigureAwait(false); - var attributes = await _repository.GetAttributesAsync(ct).ConfigureAwait(false); - - var attrsByGobject = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray()); - var nameByGobject = hierarchy.ToDictionary(o => o.GobjectId, o => o.TagName); - - var objects = hierarchy.Select(o => new GalaxyObjectInfo - { - ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName, - TagName = o.TagName, - ParentContainedName = o.ParentGobjectId != 0 && nameByGobject.TryGetValue(o.ParentGobjectId, out var p) ? p : null, - TemplateCategory = MapCategory(o.CategoryId), - Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty(), - }).ToArray(); - - // PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise - // them on demand. Format matches the Galaxy reference grammar .. - var freshAlarmTags = attributes - .Where(a => a.IsAlarm) - .Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn) - ? tn + "." + a.AttributeName - : null) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .Cast() - .ToArray(); - while (_discoveredAlarmTags.TryTake(out _)) { } - foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t); - - // PR 13: Sync the per-platform probe manager against the just-discovered hierarchy - // so ScanState subscriptions track the current runtime set. Best-effort — probe - // failures don't block Discover from returning, since the gateway-level signal from - // MxAccessClient.ConnectionStateChanged still flows and the Admin UI degrades to - // that level if any per-host probe couldn't advise. - try - { - var targets = hierarchy - .Where(o => o.CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform - || o.CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine) - .Select(o => new HostProbeTarget(o.TagName, o.CategoryId)); - await _probeManager.SyncAsync(targets).ConfigureAwait(false); - } - catch { /* swallow — Discover succeeded; probes are a diagnostic enrichment */ } - - return new DiscoverHierarchyResponse { Success = true, Objects = objects }; - } - catch (Exception ex) - { - return new DiscoverHierarchyResponse { Success = false, Error = ex.Message, Objects = Array.Empty() }; - } - } - - public async Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct) - { - if (!_mx.IsConnected) return new ReadValuesResponse { Success = false, Error = "Not connected", Values = Array.Empty() }; - - var results = new List(req.TagReferences.Length); - foreach (var reference in req.TagReferences) - { - try - { - var vtq = await _mx.ReadAsync(reference, TimeSpan.FromSeconds(5), ct); - results.Add(ToWire(reference, vtq)); - } - catch (Exception ex) - { - results.Add(new GalaxyDataValue - { - TagReference = reference, - StatusCode = 0x80020000u, // Bad_InternalError - ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - ValueBytes = MessagePackSerializer.Serialize(ex.Message), - }); - } - } - - return new ReadValuesResponse { Success = true, Values = results.ToArray() }; - } - - public async Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct) - { - var results = new List(req.Writes.Length); - foreach (var w in req.Writes) - { - try - { - // Decode the value back from the MessagePack bytes the Proxy sent. - var value = w.ValueBytes is null - ? null - : MessagePackSerializer.Deserialize(w.ValueBytes); - - var ok = await _mx.WriteAsync(w.TagReference, value!); - results.Add(new WriteValueResult - { - TagReference = w.TagReference, - StatusCode = ok ? 0u : 0x80020000u, // Good or Bad_InternalError - Error = ok ? null : "MXAccess runtime reported write failure", - }); - } - catch (Exception ex) - { - results.Add(new WriteValueResult { TagReference = w.TagReference, StatusCode = 0x80020000u, Error = ex.Message }); - } - } - return new WriteValuesResponse { Results = results.ToArray() }; - } - - public async Task SubscribeAsync(SubscribeRequest req, CancellationToken ct) - { - var sid = Interlocked.Increment(ref _nextSubscriptionId); - - try - { - foreach (var tag in req.TagReferences) - { - _refToSubs.AddOrUpdate(tag, - _ => new System.Collections.Concurrent.ConcurrentBag { sid }, - (_, bag) => { bag.Add(sid); return bag; }); - - // The MXAccess SubscribeAsync only takes one callback per tag; the same callback - // fires for every active subscription of that tag — we fan out by SubscriptionId. - await _mx.SubscribeAsync(tag, OnTagValueChanged); - } - - _subs[sid] = req.TagReferences; - return new SubscribeResponse { Success = true, SubscriptionId = sid, ActualIntervalMs = req.RequestedIntervalMs }; - } - catch (Exception ex) - { - return new SubscribeResponse { Success = false, Error = ex.Message }; - } - } - - public async Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) - { - if (!_subs.TryRemove(req.SubscriptionId, out var refs)) return; - foreach (var r in refs) - { - // Drop this subscription from the reverse map; only unsubscribe from MXAccess if no - // other subscription is still listening (multiple Proxy subs may share a tag). - _refToSubs.TryGetValue(r, out var bag); - if (bag is not null) - { - var remaining = new System.Collections.Concurrent.ConcurrentBag( - bag.Where(id => id != req.SubscriptionId)); - if (remaining.IsEmpty) - { - _refToSubs.TryRemove(r, out _); - await _mx.UnsubscribeAsync(r); - } - else - { - _refToSubs[r] = remaining; - } - } - } - } - - /// - /// Fires for every value change on any subscribed Galaxy attribute. Wraps the value in - /// a and raises once per - /// subscription that includes this tag — the IPC sink translates that into outbound - /// OnDataChangeNotification frames. - /// - private void OnTagValueChanged(string fullReference, MxAccess.Vtq vtq) - { - if (!_refToSubs.TryGetValue(fullReference, out var bag) || bag.IsEmpty) return; - - var wireValue = ToWire(fullReference, vtq); - // Emit one notification per active SubscriptionId for this tag — the Proxy fans out to - // each ISubscribable consumer based on the SubscriptionId in the payload. - foreach (var sid in bag.Distinct()) - { - OnDataChange?.Invoke(this, new OnDataChangeNotification - { - SubscriptionId = sid, - Values = new[] { wireValue }, - }); - } - } - - /// - /// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm — - /// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer - /// partial alarm coverage to none. Idempotent on repeat calls (tracker internally - /// skips already-tracked alarms). - /// - public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) - { - foreach (var tag in _discoveredAlarmTags) - { - try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); } - catch { /* swallow per-alarm — tracker rolls back its own state on failure */ } - } - } - - /// - /// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the - /// incoming request maps directly to the alarm full reference (Proxy-side naming - /// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId). - /// - public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) - { - // EventId carries a per-transition Guid.ToString("N"); there's no reverse map from - // event id to alarm tag yet, so v1's convention (ack targets the condition) is matched - // by reading the alarm name from the Comment envelope: v1 packed "|". - // Until the Proxy is updated to send the alarm tag separately, fall back to treating - // the EventId as the alarm tag — Client CLI passes it through unchanged. - var tag = req.EventId; - if (!string.IsNullOrWhiteSpace(tag)) - { - try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); } - catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ } - } - } - - public async Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct) - { - if (_historian is null) - return new HistoryReadResponse - { - Success = false, - Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", - Tags = Array.Empty(), - }; - - var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime; - var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime; - var tags = new List(req.TagReferences.Length); - - try - { - foreach (var reference in req.TagReferences) - { - var samples = await _historian.ReadRawAsync(reference, start, end, (int)req.MaxValuesPerTag, ct).ConfigureAwait(false); - tags.Add(new HistoryTagValues - { - TagReference = reference, - Values = samples.Select(s => ToWire(reference, s)).ToArray(), - }); - } - return new HistoryReadResponse { Success = true, Tags = tags.ToArray() }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return new HistoryReadResponse - { - Success = false, - Error = $"Historian read failed: {ex.Message}", - Tags = tags.ToArray(), - }; - } - } - - public async Task HistoryReadProcessedAsync( - HistoryReadProcessedRequest req, CancellationToken ct) - { - if (_historian is null) - return new HistoryReadProcessedResponse - { - Success = false, - Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", - Values = Array.Empty(), - }; - - if (req.IntervalMs <= 0) - return new HistoryReadProcessedResponse - { - Success = false, - Error = "HistoryReadProcessed requires IntervalMs > 0", - Values = Array.Empty(), - }; - - var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime; - var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime; - - try - { - var samples = await _historian.ReadAggregateAsync( - req.TagReference, start, end, req.IntervalMs, req.AggregateColumn, ct).ConfigureAwait(false); - - var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray(); - return new HistoryReadProcessedResponse { Success = true, Values = wire }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return new HistoryReadProcessedResponse - { - Success = false, - Error = $"Historian aggregate read failed: {ex.Message}", - Values = Array.Empty(), - }; - } - } - - public async Task HistoryReadAtTimeAsync( - HistoryReadAtTimeRequest req, CancellationToken ct) - { - if (_historian is null) - return new HistoryReadAtTimeResponse - { - Success = false, - Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", - Values = Array.Empty(), - }; - - if (req.TimestampsUtcUnixMs.Length == 0) - return new HistoryReadAtTimeResponse { Success = true, Values = Array.Empty() }; - - var timestamps = req.TimestampsUtcUnixMs - .Select(ms => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime) - .ToArray(); - - try - { - var samples = await _historian.ReadAtTimeAsync(req.TagReference, timestamps, ct).ConfigureAwait(false); - var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray(); - return new HistoryReadAtTimeResponse { Success = true, Values = wire }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return new HistoryReadAtTimeResponse - { - Success = false, - Error = $"Historian at-time read failed: {ex.Message}", - Values = Array.Empty(), - }; - } - } - - public async Task HistoryReadEventsAsync( - HistoryReadEventsRequest req, CancellationToken ct) - { - if (_historian is null) - return new HistoryReadEventsResponse - { - Success = false, - Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration", - Events = Array.Empty(), - }; - - var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime; - var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime; - - try - { - var events = await _historian.ReadEventsAsync(req.SourceName, start, end, req.MaxEvents, ct).ConfigureAwait(false); - var wire = events.Select(e => new GalaxyHistoricalEvent - { - EventId = e.Id.ToString(), - SourceName = e.Source, - EventTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.EventTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(), - ReceivedTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.ReceivedTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(), - DisplayText = e.DisplayText, - Severity = e.Severity, - }).ToArray(); - return new HistoryReadEventsResponse { Success = true, Events = wire }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return new HistoryReadEventsResponse - { - Success = false, - Error = $"Historian event read failed: {ex.Message}", - Events = Array.Empty(), - }; - } - } - - public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) - => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); - - public void Dispose() - { - _alarmTracker.TransitionRaised -= _onAlarmTransition; - _alarmTracker.Dispose(); - _probeManager.StateChanged -= _onProbeStateChanged; - _probeManager.Dispose(); - _mx.ConnectionStateChanged -= _onConnectionStateChanged; - _historian?.Dispose(); - } - - private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new() - { - TagReference = reference, - ValueBytes = vtq.Value is null ? null : MessagePackSerializer.Serialize(vtq.Value), - ValueMessagePackType = 0, - StatusCode = vtq.Quality >= 192 ? 0u : 0x40000000u, // Good vs Uncertain placeholder - SourceTimestampUtcUnixMs = new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - - /// - /// Maps a (raw historian row, OPC-UA-free) to the IPC wire - /// shape. The Proxy decodes the MessagePack value and maps - /// through QualityMapper on its side of the pipe — we keep the raw byte here so - /// rich OPC DA status codes (e.g. BadNotConnected, UncertainSubNormal) survive - /// the hop intact. - /// - private static GalaxyDataValue ToWire(string reference, HistorianSample sample) => new() - { - TagReference = reference, - ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value), - ValueMessagePackType = 0, - StatusCode = HistorianQualityMapper.Map(sample.Quality), - SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - - - /// - /// Maps a (one aggregate bucket) to the IPC wire - /// shape. A null means the aggregate was - /// unavailable for the bucket — the Proxy translates that to OPC UA BadNoData. - /// - private static GalaxyDataValue ToWire(string reference, HistorianAggregateSample sample) => new() - { - TagReference = reference, - ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value), - ValueMessagePackType = 0, - StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u, - SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - - private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new() - { - AttributeName = row.AttributeName, - MxDataType = row.MxDataType, - IsArray = row.IsArray, - ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null, - SecurityClassification = row.SecurityClassification, - IsHistorized = row.IsHistorized, - IsAlarm = row.IsAlarm, - }; - - private static string MapCategory(int categoryId) => categoryId switch - { - 1 => "$WinPlatform", - 3 => "$AppEngine", - 4 => "$Area", - 10 => "$UserDefined", - 11 => "$ApplicationObject", - 13 => "$Area", - 17 => "$DeviceIntegration", - 24 => "$ViewEngine", - 26 => "$ViewApp", - _ => $"category-{categoryId}", - }; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Stability/GalaxyRuntimeProbeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Stability/GalaxyRuntimeProbeManager.cs deleted file mode 100644 index f3a2612..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Stability/GalaxyRuntimeProbeManager.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability; - -/// -/// Per-platform + per-AppEngine runtime probe. Subscribes to <TagName>.ScanState -/// for each $WinPlatform and $AppEngine gobject, tracks Unknown → Running → Stopped -/// transitions, and fires so -/// can forward per-host events through the existing IPC OnHostStatusChanged event. -/// Pure-logic state machine with an injected clock so it's deterministically testable — -/// port of v1 GalaxyRuntimeProbeManager without the OPC UA node-manager coupling. -/// -/// -/// State machine rules (documented in v1's runtimestatus.md and preserved here): -/// -/// ScanState is on-change-only — a stably-Running host may go hours without a -/// callback. Running → Stopped is driven by an explicit ScanState=false callback, -/// never by starvation. -/// Unknown → Running is a startup transition and does NOT fire StateChanged (would -/// paint every host as "just recovered" at startup, which is noise). -/// Stopped → Running and Running → Stopped fire StateChanged. Unknown → Stopped -/// fires StateChanged because that's a first-known-bad signal operators need. -/// All public methods are thread-safe. Callbacks fire outside the internal lock to -/// avoid lock inversion with caller-owned state. -/// -/// -public sealed class GalaxyRuntimeProbeManager : IDisposable -{ - public const int CategoryWinPlatform = 1; - public const int CategoryAppEngine = 3; - public const string ProbeAttribute = ".ScanState"; - - private readonly Func _clock; - private readonly Func, Task> _subscribe; - private readonly Func _unsubscribe; - private readonly object _lock = new(); - - // probe tag → per-host state - private readonly Dictionary _byProbe = new(StringComparer.OrdinalIgnoreCase); - // tag name → probe tag (for reverse lookup on the desired-set diff) - private readonly Dictionary _probeByTagName = new(StringComparer.OrdinalIgnoreCase); - private bool _disposed; - - /// - /// Fires on every state transition that operators should react to. See class remarks - /// for the rules on which transitions fire. - /// - public event EventHandler? StateChanged; - - public GalaxyRuntimeProbeManager( - Func, Task> subscribe, - Func unsubscribe) - : this(subscribe, unsubscribe, () => DateTime.UtcNow) { } - - internal GalaxyRuntimeProbeManager( - Func, Task> subscribe, - Func unsubscribe, - Func clock) - { - _subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe)); - _unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe)); - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - } - - /// Number of probes currently advised. Test/dashboard hook. - public int ActiveProbeCount - { - get { lock (_lock) return _byProbe.Count; } - } - - /// - /// Snapshot every currently-tracked host's state. One entry per probe. - /// - public IReadOnlyList SnapshotStates() - { - lock (_lock) - { - return _byProbe.Select(kv => new HostProbeSnapshot( - TagName: kv.Value.TagName, - State: kv.Value.State, - LastChangedUtc: kv.Value.LastStateChangeUtc)).ToList(); - } - } - - /// - /// Query the current runtime state for . Returns - /// when the host is not tracked. - /// - public HostRuntimeState GetState(string tagName) - { - lock (_lock) - { - if (_probeByTagName.TryGetValue(tagName, out var probe) - && _byProbe.TryGetValue(probe, out var state)) - return state.State; - return HostRuntimeState.Unknown; - } - } - - /// - /// Diff the desired host set (filtered $WinPlatform / $AppEngine from the latest Discover) - /// against the currently-tracked set and advise / unadvise as needed. Idempotent: - /// calling twice with the same set does nothing. - /// - public async Task SyncAsync(IEnumerable desiredHosts) - { - if (_disposed) return; - - var desired = desiredHosts - .Where(h => !string.IsNullOrWhiteSpace(h.TagName)) - .ToDictionary(h => h.TagName, StringComparer.OrdinalIgnoreCase); - - List toAdvise; - List toUnadvise; - lock (_lock) - { - toAdvise = desired.Keys - .Where(tag => !_probeByTagName.ContainsKey(tag)) - .ToList(); - toUnadvise = _probeByTagName.Keys - .Where(tag => !desired.ContainsKey(tag)) - .Select(tag => _probeByTagName[tag]) - .ToList(); - - foreach (var tag in toAdvise) - { - var probe = tag + ProbeAttribute; - _probeByTagName[tag] = probe; - _byProbe[probe] = new HostProbeState - { - TagName = tag, - State = HostRuntimeState.Unknown, - LastStateChangeUtc = _clock(), - }; - } - - foreach (var probe in toUnadvise) - { - _byProbe.Remove(probe); - } - - foreach (var removedTag in _probeByTagName.Keys.Where(t => !desired.ContainsKey(t)).ToList()) - { - _probeByTagName.Remove(removedTag); - } - } - - foreach (var tag in toAdvise) - { - var probe = tag + ProbeAttribute; - try - { - await _subscribe(probe, OnProbeCallback); - } - catch - { - // Rollback on subscribe failure so a later Tick can't transition a never-advised - // probe into a false Stopped state. Callers can re-Sync later to retry. - lock (_lock) - { - _byProbe.Remove(probe); - _probeByTagName.Remove(tag); - } - } - } - - foreach (var probe in toUnadvise) - { - try { await _unsubscribe(probe); } catch { /* best-effort cleanup */ } - } - } - - /// - /// Public entry point for tests and internal callbacks. Production flow: MxAccessClient's - /// SubscribeAsync delivers VTQ updates through the callback wired in , - /// which calls this method under the lock to update state and fires - /// outside the lock for any transition that matters. - /// - public void OnProbeCallback(string probeTag, Vtq vtq) - { - if (_disposed) return; - - HostStateTransition? transition = null; - lock (_lock) - { - if (!_byProbe.TryGetValue(probeTag, out var state)) return; - - var isRunning = vtq.Quality >= 192 && vtq.Value is bool b && b; - var now = _clock(); - var previous = state.State; - state.LastCallbackUtc = now; - - if (isRunning) - { - state.GoodUpdateCount++; - if (previous != HostRuntimeState.Running) - { - state.State = HostRuntimeState.Running; - state.LastStateChangeUtc = now; - if (previous == HostRuntimeState.Stopped) - { - transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Running, now); - } - } - } - else - { - state.FailureCount++; - if (previous != HostRuntimeState.Stopped) - { - state.State = HostRuntimeState.Stopped; - state.LastStateChangeUtc = now; - transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Stopped, now); - } - } - } - - if (transition is { } t) - { - StateChanged?.Invoke(this, t); - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - lock (_lock) - { - _byProbe.Clear(); - _probeByTagName.Clear(); - } - } - - private sealed class HostProbeState - { - public string TagName { get; set; } = ""; - public HostRuntimeState State { get; set; } - public DateTime LastStateChangeUtc { get; set; } - public DateTime? LastCallbackUtc { get; set; } - public long GoodUpdateCount { get; set; } - public long FailureCount { get; set; } - } -} - -public enum HostRuntimeState -{ - Unknown, - Running, - Stopped, -} - -public sealed record HostStateTransition( - string TagName, - HostRuntimeState OldState, - HostRuntimeState NewState, - DateTime AtUtc); - -public sealed record HostProbeSnapshot( - string TagName, - HostRuntimeState State, - DateTime LastChangedUtc); - -public readonly record struct HostProbeTarget(string TagName, int CategoryId) -{ - public bool IsRuntimeHost => - CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform - || CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs deleted file mode 100644 index ab8ffe6..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/StubGalaxyBackend.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; - -/// -/// Phase 2 placeholder backend — accepts session open/close + responds to recycle, returns -/// "not-implemented" results for every data-plane call. Replaced by the lifted -/// MxAccessClient-backed implementation during the deferred Galaxy code move -/// (Task B.1 + parity gate). Keeps the IPC end-to-end testable today. -/// -public sealed class StubGalaxyBackend : IGalaxyBackend -{ - private long _nextSessionId; - private long _nextSubscriptionId; - - // Stub backend never raises events — implements the interface members for symmetry. -#pragma warning disable CS0067 - public event System.EventHandler? OnDataChange; - public event System.EventHandler? OnAlarmEvent; - public event System.EventHandler? OnHostStatusChanged; -#pragma warning restore CS0067 - - public Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct) - { - var id = Interlocked.Increment(ref _nextSessionId); - return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id }); - } - - public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask; - - public Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct) - => Task.FromResult(new DiscoverHierarchyResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Objects = System.Array.Empty(), - }); - - public Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct) - => Task.FromResult(new ReadValuesResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Values = System.Array.Empty(), - }); - - public Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct) - { - var results = new WriteValueResult[req.Writes.Length]; - for (var i = 0; i < req.Writes.Length; i++) - { - results[i] = new WriteValueResult - { - TagReference = req.Writes[i].TagReference, - StatusCode = 0x80020000u, // Bad_InternalError - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - }; - } - return Task.FromResult(new WriteValuesResponse { Results = results }); - } - - public Task SubscribeAsync(SubscribeRequest req, CancellationToken ct) - { - var sid = Interlocked.Increment(ref _nextSubscriptionId); - return Task.FromResult(new SubscribeResponse - { - Success = true, - SubscriptionId = sid, - ActualIntervalMs = req.RequestedIntervalMs, - }); - } - - public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask; - - public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask; - public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask; - - public Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Tags = System.Array.Empty(), - }); - - public Task HistoryReadProcessedAsync( - HistoryReadProcessedRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadProcessedResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Values = System.Array.Empty(), - }); - - public Task HistoryReadAtTimeAsync( - HistoryReadAtTimeRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadAtTimeResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Values = System.Array.Empty(), - }); - - public Task HistoryReadEventsAsync( - HistoryReadEventsRequest req, CancellationToken ct) - => Task.FromResult(new HistoryReadEventsResponse - { - Success = false, - Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)", - Events = System.Array.Empty(), - }); - - public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) - => Task.FromResult(new RecycleStatusResponse - { - Accepted = true, - GraceSeconds = 15, // matches Phase 2 plan §B.8 default - }); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs deleted file mode 100644 index dd7fa64..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/GalaxyFrameHandler.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; - -/// -/// Real IPC dispatcher — routes each to the matching -/// method. Replaces . Heartbeat -/// stays handled inline so liveness detection works regardless of backend health. -/// -public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) : IFrameHandler -{ - public async Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) - { - try - { - switch (kind) - { - case MessageKind.Heartbeat: - { - var hb = Deserialize(body); - await writer.WriteAsync(MessageKind.HeartbeatAck, - new HeartbeatAck { SequenceNumber = hb.SequenceNumber, UtcUnixMs = hb.UtcUnixMs }, ct); - return; - } - case MessageKind.OpenSessionRequest: - { - var resp = await backend.OpenSessionAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.OpenSessionResponse, resp, ct); - return; - } - case MessageKind.CloseSessionRequest: - await backend.CloseSessionAsync(Deserialize(body), ct); - return; // one-way - - case MessageKind.DiscoverHierarchyRequest: - { - var resp = await backend.DiscoverAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.DiscoverHierarchyResponse, resp, ct); - return; - } - case MessageKind.ReadValuesRequest: - { - var resp = await backend.ReadValuesAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.ReadValuesResponse, resp, ct); - return; - } - case MessageKind.WriteValuesRequest: - { - var resp = await backend.WriteValuesAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.WriteValuesResponse, resp, ct); - return; - } - case MessageKind.SubscribeRequest: - { - var resp = await backend.SubscribeAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.SubscribeResponse, resp, ct); - return; - } - case MessageKind.UnsubscribeRequest: - await backend.UnsubscribeAsync(Deserialize(body), ct); - return; // one-way - - case MessageKind.AlarmSubscribeRequest: - await backend.SubscribeAlarmsAsync(Deserialize(body), ct); - return; // one-way; subsequent alarm events are server-pushed - case MessageKind.AlarmAckRequest: - await backend.AcknowledgeAlarmAsync(Deserialize(body), ct); - return; - - case MessageKind.HistoryReadRequest: - { - var resp = await backend.HistoryReadAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct); - return; - } - case MessageKind.HistoryReadProcessedRequest: - { - var resp = await backend.HistoryReadProcessedAsync( - Deserialize(body), ct); - await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct); - return; - } - case MessageKind.HistoryReadAtTimeRequest: - { - var resp = await backend.HistoryReadAtTimeAsync( - Deserialize(body), ct); - await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, resp, ct); - return; - } - case MessageKind.HistoryReadEventsRequest: - { - var resp = await backend.HistoryReadEventsAsync( - Deserialize(body), ct); - await writer.WriteAsync(MessageKind.HistoryReadEventsResponse, resp, ct); - return; - } - case MessageKind.RecycleHostRequest: - { - var resp = await backend.RecycleAsync(Deserialize(body), ct); - await writer.WriteAsync(MessageKind.RecycleStatusResponse, resp, ct); - return; - } - default: - await SendErrorAsync(writer, "unknown-kind", $"Frame kind {kind} not handled by Host", ct); - return; - } - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - logger.Error(ex, "GalaxyFrameHandler threw on {Kind}", kind); - await SendErrorAsync(writer, "handler-exception", ex.Message, ct); - } - } - - /// - /// Subscribes the backend's server-pushed events for the lifetime of the connection. - /// The returned disposable unsubscribes when the connection closes — without it the - /// backend's static event invocation list would accumulate dead writer references and - /// leak memory + raise on every push. - /// - public IDisposable AttachConnection(FrameWriter writer) - { - var sink = new ConnectionSink(backend, writer, logger); - sink.Attach(); - return sink; - } - - private static T Deserialize(byte[] body) => MessagePackSerializer.Deserialize(body); - - private static Task SendErrorAsync(FrameWriter writer, string code, string message, CancellationToken ct) - => writer.WriteAsync(MessageKind.ErrorResponse, - new ErrorResponse { Code = code, Message = message }, ct); - - private sealed class ConnectionSink : IDisposable - { - private readonly IGalaxyBackend _backend; - private readonly FrameWriter _writer; - private readonly ILogger _logger; - private EventHandler? _onData; - private EventHandler? _onAlarm; - private EventHandler? _onHost; - - public ConnectionSink(IGalaxyBackend backend, FrameWriter writer, ILogger logger) - { - _backend = backend; _writer = writer; _logger = logger; - } - - public void Attach() - { - _onData = (_, e) => Push(MessageKind.OnDataChangeNotification, e); - _onAlarm = (_, e) => Push(MessageKind.AlarmEvent, e); - _onHost = (_, e) => Push(MessageKind.RuntimeStatusChange, - new RuntimeStatusChangeNotification { Status = e }); - _backend.OnDataChange += _onData; - _backend.OnAlarmEvent += _onAlarm; - _backend.OnHostStatusChanged += _onHost; - } - - private void Push(MessageKind kind, T payload) - { - // Fire-and-forget — pushes can race with disposal of the writer. We swallow - // ObjectDisposedException because the dispose path will detach this sink shortly. - try { _writer.WriteAsync(kind, payload, CancellationToken.None).GetAwaiter().GetResult(); } - catch (ObjectDisposedException) { } - catch (Exception ex) { _logger.Warning(ex, "ConnectionSink push failed for {Kind}", kind); } - } - - public void Dispose() - { - if (_onData is not null) _backend.OnDataChange -= _onData; - if (_onAlarm is not null) _backend.OnAlarmEvent -= _onAlarm; - if (_onHost is not null) _backend.OnHostStatusChanged -= _onHost; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs deleted file mode 100644 index a66d2ef..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.AccessControl; -using System.Security.Principal; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; - -/// -/// Builds the required by driver-stability.md §"IPC Security": -/// only the configured OtOpcUa server principal SID gets ReadWrite | Synchronize; -/// LocalSystem is explicitly denied. Any other authenticated user falls through to the -/// implicit deny. -/// -/// -/// Earlier revisions also denied BUILTIN\Administrators, which broke live testing -/// on dev boxes where the allowed user (dohertj2) is also a member of the local -/// Administrators group — UAC's filtered token still carries the Admins SID as deny-only, -/// so the deny ACE fired even from non-elevated shells. The per-connection -/// check already gates on the exact allowed SID, -/// which is the real authorization boundary, so the Admins deny added no defence in depth -/// in that topology. -/// -public static class PipeAcl -{ - public static PipeSecurity Create(SecurityIdentifier allowedSid) - { - if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid)); - - var security = new PipeSecurity(); - - security.AddAccessRule(new PipeAccessRule( - allowedSid, - PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize, - AccessControlType.Allow)); - - var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); - if (allowedSid != localSystem) - security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny)); - - // Owner = allowed SID so the deny rules can't be removed without write-DACL rights. - security.SetOwner(allowedSid); - - return security; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs deleted file mode 100644 index 5994d9d..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.IO.Pipes; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; - -/// -/// Accepts one client connection at a time on a named pipe with the strict ACL from -/// . Verifies the peer SID and the per-process shared secret before any -/// RPC frame is accepted. Per driver-stability.md §"IPC Security". -/// -public sealed class PipeServer : IDisposable -{ - private readonly string _pipeName; - private readonly SecurityIdentifier _allowedSid; - private readonly string _sharedSecret; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cts = new(); - private NamedPipeServerStream? _current; - - public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger) - { - _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName)); - _allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid)); - _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Accepts one connection, performs Hello handshake, then dispatches frames to - /// until EOF or cancel. Returns when the client disconnects. - /// - public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct); - var acl = PipeAcl.Create(_allowedSid); - - // .NET Framework 4.8 uses the legacy constructor overload that takes a PipeSecurity directly. - _current = new NamedPipeServerStream( - _pipeName, - PipeDirection.InOut, - maxNumberOfServerInstances: 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous, - inBufferSize: 64 * 1024, - outBufferSize: 64 * 1024, - pipeSecurity: acl); - - try - { - await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false); - - using var reader = new FrameReader(_current, leaveOpen: true); - using var writer = new FrameWriter(_current, leaveOpen: true); - - // First frame must be a Hello with the correct shared secret. Reading it before - // the caller-SID impersonation check satisfies Windows' ERROR_CANNOT_IMPERSONATE - // rule — ImpersonateNamedPipeClient fails until at least one frame has been read. - var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false); - if (first is null || first.Value.Kind != MessageKind.Hello) - { - _logger.Warning("IPC first frame was not Hello; dropping"); - return; - } - - if (!VerifyCaller(_current, out var reason)) - { - _logger.Warning("IPC caller rejected: {Reason}", reason); - _current.Disconnect(); - 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("IPC 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("IPC 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); - - using var attachment = handler.AttachConnection(writer); - - 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); - } - } - finally - { - _current.Dispose(); - _current = null; - } - } - - /// - /// Runs the server continuously, handling one connection at a time. When a connection ends - /// (clean or error), accepts the next. - /// - public async Task RunAsync(IFrameHandler handler, CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); } - catch (OperationCanceledException) { break; } - catch (Exception ex) { _logger.Error(ex, "IPC connection loop error — accepting next"); } - } - } - - private bool VerifyCaller(NamedPipeServerStream pipe, out string reason) - { - try - { - pipe.RunAsClient(() => - { - using var wi = WindowsIdentity.GetCurrent(); - if (wi.User is null) - throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller"); - if (wi.User != _allowedSid) - throw new UnauthorizedAccessException( - $"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}"); - }); - reason = string.Empty; - return true; - } - catch (Exception ex) { reason = ex.Message; return false; } - } - - public void Dispose() - { - _cts.Cancel(); - _current?.Dispose(); - _cts.Dispose(); - } -} - -public interface IFrameHandler -{ - Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct); - - /// - /// Called once per accepted connection after the Hello handshake. Lets the handler - /// attach server-pushed event sinks (data-change, alarm, host-status) to the - /// connection's . Returns an the - /// pipe server disposes when the connection closes — backends use it to unsubscribe. - /// Implementations that don't push events can return . - /// - IDisposable AttachConnection(FrameWriter writer); - - public sealed class NoopAttachment : IDisposable - { - public static readonly NoopAttachment Instance = new(); - public void Dispose() { } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs deleted file mode 100644 index fcbf15e..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; - -/// -/// Placeholder handler that responds to the framed IPC with error responses. Replaced by the -/// real Galaxy-backed handler when the MXAccess code move (deferred) lands. -/// -public sealed class StubFrameHandler : IFrameHandler -{ - public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) - { - // Minimal lifecycle: heartbeat ack keeps the supervisor's liveness detector happy even - // while the data-plane is stubbed, so integration tests of the supervisor can run end-to-end. - if (kind == MessageKind.Heartbeat) - { - var hb = MessagePackSerializer.Deserialize(body); - return writer.WriteAsync(MessageKind.HeartbeatAck, - new HeartbeatAck { SequenceNumber = hb.SequenceNumber, UtcUnixMs = hb.UtcUnixMs }, ct); - } - - return writer.WriteAsync(MessageKind.ErrorResponse, - new ErrorResponse { Code = "not-implemented", Message = $"Kind {kind} is stubbed — MXAccess lift deferred" }, - ct); - } - - public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs deleted file mode 100644 index ec909f1..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs +++ /dev/null @@ -1,5 +0,0 @@ -// Shim — .NET Framework 4.8 doesn't ship with IsExternalInit, required for init-only setters + -// positional records. Safe to add in our own namespace; the compiler accepts any type with this name. -namespace System.Runtime.CompilerServices; - -internal static class IsExternalInit; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs deleted file mode 100644 index 15fc144..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Security.Principal; -using System.Threading; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host; - -/// -/// Entry point for the OtOpcUaGalaxyHost Windows service / console host. Reads the -/// pipe name, allowed-SID, and shared secret from environment (passed by the supervisor at -/// spawn time per driver-stability.md). -/// -public static class Program -{ - public static int Main(string[] args) - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.File( - @"%ProgramData%\OtOpcUa\galaxy-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)), - rollingInterval: RollingInterval.Day) - .CreateLogger(); - - try - { - var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_PIPE") ?? "OtOpcUaGalaxy"; - var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID") - ?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID"); - var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_SECRET") - ?? throw new InvalidOperationException("OTOPCUA_GALAXY_SECRET not set — supervisor must pass the per-process secret at spawn time"); - - var allowedSid = new SecurityIdentifier(allowedSidValue); - - using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger); - using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; - - Log.Information("OtOpcUaGalaxyHost starting — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue); - - // Backend selection — env var picks the implementation: - // OTOPCUA_GALAXY_BACKEND=stub → StubGalaxyBackend (no Galaxy required) - // OTOPCUA_GALAXY_BACKEND=db → DbBackedGalaxyBackend (Discover only, against ZB) - // OTOPCUA_GALAXY_BACKEND=mxaccess → MxAccessGalaxyBackend (real COM + ZB; default) - var backendKind = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_BACKEND")?.ToLowerInvariant() ?? "mxaccess"; - var zbConn = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_ZB_CONN") - ?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; - var clientName = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_CLIENT_NAME") ?? "OtOpcUa-Galaxy.Host"; - - IGalaxyBackend backend; - StaPump? pump = null; - MxAccessClient? mx = null; - switch (backendKind) - { - case "stub": - backend = new StubGalaxyBackend(); - break; - case "db": - backend = new DbBackedGalaxyBackend(new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = zbConn })); - break; - default: // mxaccess - pump = new StaPump("Galaxy.Sta"); - pump.WaitForStartedAsync().GetAwaiter().GetResult(); - mx = new MxAccessClient(pump, new MxProxyAdapter(), clientName); - var historian = BuildHistorianIfEnabled(); - backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = zbConn }), - mx, - historian); - break; - } - - Log.Information("OtOpcUaGalaxyHost backend={Backend}", backendKind); - var handler = new GalaxyFrameHandler(backend, Log.Logger); - try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); } - finally - { - (backend as IDisposable)?.Dispose(); - mx?.Dispose(); - pump?.Dispose(); - } - - Log.Information("OtOpcUaGalaxyHost stopped cleanly"); - return 0; - } - catch (Exception ex) - { - Log.Fatal(ex, "OtOpcUaGalaxyHost fatal"); - return 2; - } - finally { Log.CloseAndFlush(); } - } - - /// - /// Builds a from the OTOPCUA_HISTORIAN_* environment - /// variables the supervisor passes at spawn time. Returns null when the historian is - /// disabled (default) so MxAccessGalaxyBackend.HistoryReadAsync returns a clear - /// "not configured" error instead of attempting an SDK connection to localhost. - /// - private static IHistorianDataSource? BuildHistorianIfEnabled() - { - var enabled = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"); - if (!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase) && enabled != "1") - return null; - - 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("Historian enabled — {NodeCount} configured 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; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs deleted file mode 100644 index 80a4b07..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Runtime.ConstrainedExecution; -using System.Runtime.InteropServices; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -/// -/// SafeHandle-style lifetime wrapper for an LMXProxyServer COM connection. Per Task B.3 -/// + decision #65: must call Marshal.ReleaseComObject until -/// refcount = 0, then UnregisterProxy. The finalizer runs as a -/// to honor AppDomain-unload ordering. -/// -/// -/// This scaffold accepts any RCW (tagged as ) so we can unit-test the -/// release logic with a mock. The concrete wiring to ArchestrA.MxAccess.LMXProxyServer -/// lands when the actual Galaxy code moves over (the part deferred to the parity gate). -/// -public sealed class MxAccessHandle : SafeHandle -{ - private object? _comObject; - private readonly Action? _unregister; - - public MxAccessHandle(object comObject, Action? unregister = null) - : base(IntPtr.Zero, ownsHandle: true) - { - _comObject = comObject ?? throw new ArgumentNullException(nameof(comObject)); - _unregister = unregister; - - // The pointer value itself doesn't matter — we're wrapping an RCW, not a native handle. - SetHandle(new IntPtr(1)); - } - - public override bool IsInvalid => handle == IntPtr.Zero; - - public object? RawComObject => _comObject; - - [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)] - protected override bool ReleaseHandle() - { - if (_comObject is null) return true; - - try { _unregister?.Invoke(_comObject); } - catch { /* swallow — we're in finalizer/cleanup; log elsewhere */ } - - try - { - if (Marshal.IsComObject(_comObject)) - { - while (Marshal.ReleaseComObject(_comObject) > 0) { /* loop until fully released */ } - } - } - catch { /* swallow */ } - - _comObject = null; - SetHandle(IntPtr.Zero); - return true; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs deleted file mode 100644 index ae67c93..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -/// -/// Dedicated STA thread with a Win32 message pump that owns all LMXProxyServer COM -/// instances. Lifted from v1 StaComThread per CLAUDE.md "Reference Implementation". -/// Per driver-stability.md Galaxy deep dive §"STA thread + Win32 message pump": -/// work items dispatched via PostThreadMessage(WM_APP); WM_APP+1 requests a -/// graceful drain → WM_QUIT; supervisor escalates to Environment.Exit(2) if the -/// pump doesn't drain within the recycle grace window. -/// -public sealed class StaPump : IDisposable -{ - private const uint WM_APP = 0x8000; - private const uint WM_DRAIN_AND_QUIT = WM_APP + 1; - private const uint PM_NOREMOVE = 0x0000; - - private readonly Thread _thread; - private readonly ConcurrentQueue _workItems = new(); - private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); - - private volatile uint _nativeThreadId; - private volatile bool _pumpExited; - private volatile bool _disposed; - - public int ThreadId => _thread.ManagedThreadId; - public DateTime LastDispatchedUtc { get; private set; } = DateTime.MinValue; - public int QueueDepth => _workItems.Count; - public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited; - - public StaPump(string name = "Galaxy.Sta") - { - _thread = new Thread(PumpLoop) { Name = name, IsBackground = true }; - _thread.SetApartmentState(ApartmentState.STA); - _thread.Start(); - } - - public Task WaitForStartedAsync() => _started.Task; - - /// Posts a work item; resolves once it's executed on the STA thread. - public Task InvokeAsync(Func work) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaPump)); - if (_pumpExited) throw new InvalidOperationException("STA pump has exited"); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _workItems.Enqueue(new WorkItem( - () => - { - try { tcs.TrySetResult(work()); } - catch (Exception ex) { tcs.TrySetException(ex); } - }, - ex => tcs.TrySetException(ex))); - - if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero)) - { - _pumpExited = true; - DrainAndFaultQueue(); - } - - return tcs.Task; - } - - public Task InvokeAsync(Action work) => InvokeAsync(() => { work(); return 0; }); - - /// - /// Health probe — returns true if a no-op work item round-trips within - /// . Used by the supervisor; timeout means the pump is wedged - /// and a recycle is warranted (Task B.2 acceptance). - /// - public async Task IsResponsiveAsync(TimeSpan timeout) - { - if (!IsRunning) return false; - var task = InvokeAsync(() => { }); - var completed = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false); - return completed == task; - } - - private void PumpLoop() - { - try - { - _nativeThreadId = GetCurrentThreadId(); - - // Force the system to create the thread message queue before we signal Started. - // PeekMessage(PM_NOREMOVE) on an empty queue is the documented way to do this. - PeekMessage(out _, IntPtr.Zero, 0, 0, PM_NOREMOVE); - - _started.TrySetResult(true); - - // GetMessage returns 0 on WM_QUIT, -1 on error, otherwise a positive value. - while (GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) - { - if (msg.message == WM_APP) - { - DrainQueue(); - } - else if (msg.message == WM_DRAIN_AND_QUIT) - { - DrainQueue(); - PostQuitMessage(0); - } - else - { - // Pass through any window/dialog messages the COM proxy may inject. - TranslateMessage(ref msg); - DispatchMessage(ref msg); - } - } - } - catch (Exception ex) - { - _started.TrySetException(ex); - } - finally - { - _pumpExited = true; - DrainAndFaultQueue(); - } - } - - private void DrainQueue() - { - while (_workItems.TryDequeue(out var item)) - { - item.Execute(); - LastDispatchedUtc = DateTime.UtcNow; - } - } - - private void DrainAndFaultQueue() - { - var ex = new InvalidOperationException("STA pump has exited"); - while (_workItems.TryDequeue(out var item)) - { - try { item.Fault(ex); } - catch { /* faulting a TCS shouldn't throw, but be defensive */ } - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - if (_nativeThreadId != 0 && !_pumpExited) - PostThreadMessage(_nativeThreadId, WM_DRAIN_AND_QUIT, IntPtr.Zero, IntPtr.Zero); - _thread.Join(TimeSpan.FromSeconds(5)); - } - catch { /* swallow — best effort */ } - - DrainAndFaultQueue(); - } - - private sealed record WorkItem(Action Execute, Action Fault); - - #region Win32 P/Invoke - - [StructLayout(LayoutKind.Sequential)] - private struct MSG - { - public IntPtr hwnd; - public uint message; - public IntPtr wParam; - public IntPtr lParam; - public uint time; - public POINT pt; - } - - [StructLayout(LayoutKind.Sequential)] - private struct POINT { public int x; public int y; } - - [DllImport("user32.dll")] - private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool TranslateMessage(ref MSG lpMsg); - - [DllImport("user32.dll")] - private static extern IntPtr DispatchMessage(ref MSG lpMsg); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll")] - private static extern void PostQuitMessage(int nExitCode); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, - uint wRemoveMsg); - - [DllImport("kernel32.dll")] - private static extern uint GetCurrentThreadId(); - - #endregion -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs deleted file mode 100644 index 5777c36..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -/// -/// Galaxy-specific RSS watchdog per driver-stability.md §"Memory Watchdog Thresholds". -/// Baseline-relative + absolute caps. Sustained-slope detection uses a rolling 30-min window. -/// Pluggable RSS source keeps it unit-testable. -/// -public sealed class MemoryWatchdog -{ - /// Absolute hard ceiling — process is force-killed above this. - public long HardCeilingBytes { get; init; } = 1_500L * 1024 * 1024; - - /// Sustained slope (bytes/min) above which soft recycle is scheduled. - public long SustainedSlopeBytesPerMinute { get; init; } = 5L * 1024 * 1024; - - public TimeSpan SlopeWindow { get; init; } = TimeSpan.FromMinutes(30); - - private readonly long _baselineBytes; - private readonly Queue _samples = new(); - - public MemoryWatchdog(long baselineBytes) - { - _baselineBytes = baselineBytes; - } - - /// Called every 30s with the current RSS. Returns the action the supervisor should take. - public WatchdogAction Sample(long rssBytes, DateTime utcNow) - { - _samples.Enqueue(new RssSample(utcNow, rssBytes)); - while (_samples.Count > 0 && utcNow - _samples.Peek().TimestampUtc > SlopeWindow) - _samples.Dequeue(); - - if (rssBytes >= HardCeilingBytes) - return WatchdogAction.HardKill; - - var softThreshold = Math.Max(_baselineBytes * 2, _baselineBytes + 200L * 1024 * 1024); - var warnThreshold = Math.Max((long)(_baselineBytes * 1.5), _baselineBytes + 200L * 1024 * 1024); - - if (rssBytes >= softThreshold) return WatchdogAction.SoftRecycle; - if (rssBytes >= warnThreshold) return WatchdogAction.Warn; - - if (_samples.Count >= 2) - { - var oldest = _samples.Peek(); - var span = (utcNow - oldest.TimestampUtc).TotalMinutes; - if (span >= SlopeWindow.TotalMinutes * 0.9) // need ~full window to trust the slope - { - var delta = rssBytes - oldest.RssBytes; - var bytesPerMin = delta / span; - if (bytesPerMin >= SustainedSlopeBytesPerMinute) - return WatchdogAction.SoftRecycle; - } - } - - return WatchdogAction.None; - } - - private readonly record struct RssSample(DateTime TimestampUtc, long RssBytes); -} - -public enum WatchdogAction { None, Warn, SoftRecycle, HardKill } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs deleted file mode 100644 index abe98c7..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.IO; -using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; -using System.Text; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -/// -/// Ring-buffer of the last IPC operations, written into a -/// memory-mapped file. On hard crash the supervisor reads the MMF after the corpse is gone -/// to see what was in flight. Thread-safe for the single-writer, multi-reader pattern. -/// -/// -/// File layout: -/// -/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)] -/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]] -/// -/// -public sealed class PostMortemMmf : IDisposable -{ - private const int Magic = 0x4F505043; // 'OPPC' - private const int Version = 1; - private const int HeaderBytes = 16; - public const int EntryBytes = 256; - private const int MessageOffset = 16; - private const int MessageCapacity = EntryBytes - MessageOffset; - - public int Capacity { get; } - public string Path { get; } - - private readonly MemoryMappedFile _mmf; - private readonly MemoryMappedViewAccessor _accessor; - private readonly object _writeGate = new(); - - public PostMortemMmf(string path, int capacity = 1000) - { - if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); - Capacity = capacity; - Path = path; - - var fileBytes = HeaderBytes + capacity * EntryBytes; - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); - - var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read); - fs.SetLength(fileBytes); - _mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes, - MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false); - _accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite); - - // Initialize header if blank/garbage. - if (_accessor.ReadInt32(0) != Magic) - { - _accessor.Write(0, Magic); - _accessor.Write(4, Version); - _accessor.Write(8, capacity); - _accessor.Write(12, 0); // writeIndex - } - } - - public void Write(long opKind, string message) - { - lock (_writeGate) - { - var idx = _accessor.ReadInt32(12); - var offset = HeaderBytes + idx * EntryBytes; - - _accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - _accessor.Write(offset + 8, opKind); - - var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty); - var copy = Math.Min(msgBytes.Length, MessageCapacity - 1); - _accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy); - _accessor.Write(offset + MessageOffset + copy, (byte)0); // null terminator - - var next = (idx + 1) % Capacity; - _accessor.Write(12, next); - } - } - - /// Reads all entries in order (oldest → newest). Safe to call from another process. - public PostMortemEntry[] ReadAll() - { - var magic = _accessor.ReadInt32(0); - if (magic != Magic) return []; - - var capacity = _accessor.ReadInt32(8); - var writeIndex = _accessor.ReadInt32(12); - - var entries = new PostMortemEntry[capacity]; - var count = 0; - for (var i = 0; i < capacity; i++) - { - var slot = (writeIndex + i) % capacity; - var offset = HeaderBytes + slot * EntryBytes; - - var ts = _accessor.ReadInt64(offset + 0); - if (ts == 0) continue; // unwritten - - var op = _accessor.ReadInt64(offset + 8); - var msgBuf = new byte[MessageCapacity]; - _accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity); - var nulTerm = Array.IndexOf(msgBuf, 0); - var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm); - - entries[count++] = new PostMortemEntry(ts, op, msg); - } - - Array.Resize(ref entries, count); - return entries; - } - - public void Dispose() - { - _accessor.Dispose(); - _mmf.Dispose(); - } -} - -public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs deleted file mode 100644 index 35cc834..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -/// -/// Frequency-capped soft-recycle decision per driver-stability.md §"Recycle Policy". -/// Default cap: 1 soft recycle per hour. Scheduled recycle at 03:00 local; supervisor reads -/// to decide. -/// -public sealed class RecyclePolicy -{ - public TimeSpan SoftRecycleCap { get; init; } = TimeSpan.FromHours(1); - public int DailyRecycleHourLocal { get; init; } = 3; - - private readonly List _recentRecyclesUtc = new(); - - /// Returns true if a soft recycle would be allowed under the frequency cap. - public bool TryRequestSoftRecycle(DateTime utcNow, out string? reason) - { - _recentRecyclesUtc.RemoveAll(t => utcNow - t > SoftRecycleCap); - if (_recentRecyclesUtc.Count > 0) - { - reason = $"soft-recycle frequency cap: last recycle was {(utcNow - _recentRecyclesUtc[_recentRecyclesUtc.Count - 1]).TotalMinutes:F1} min ago"; - return false; - } - _recentRecyclesUtc.Add(utcNow); - reason = null; - return true; - } - - public bool ShouldSoftRecycleScheduled(DateTime localNow, ref DateTime lastScheduledDateLocal) - { - if (localNow.Hour != DailyRecycleHourLocal) return false; - if (localNow.Date <= lastScheduledDateLocal.Date) return false; - - lastScheduledDateLocal = localNow.Date; - return true; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj deleted file mode 100644 index 55bd8f3..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj +++ /dev/null @@ -1,53 +0,0 @@ - - - - Exe - net48 - - x86 - true - enable - latest - true - true - $(NoWarn);CS1591 - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host - OtOpcUa.Driver.Galaxy.Host - - - - - - - - - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MxAccess.dll - true - - - - - - - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs deleted file mode 100644 index 1e43a28..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs +++ /dev/null @@ -1,590 +0,0 @@ -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; -using IpcHostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts.HostConnectivityStatus; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; - -/// -/// implementation that forwards every capability over the Galaxy IPC -/// channel to the out-of-process Host. Implements the full Phase 2 capability surface; -/// bodies that depend on the deferred Host-side MXAccess code lift will surface -/// with code not-implemented until the Host's -/// IGalaxyBackend is wired to the real MxAccessClient. -/// -public sealed class GalaxyProxyDriver(GalaxyProxyOptions options) - : IDriver, - ITagDiscovery, - IReadable, - IWritable, - ISubscribable, - IAlarmSource, - IHistoryProvider, - IRediscoverable, - IHostConnectivityProbe, - IAlarmHistorianWriter, - IDisposable -{ - private GalaxyIpcClient? _client; - private long _sessionId; - private DriverHealth _health = new(DriverState.Unknown, null, null); - - private IReadOnlyList _hostStatuses = []; - - public string DriverInstanceId => options.DriverInstanceId; - public string DriverType => "Galaxy"; - - public event EventHandler? OnDataChange; - public event EventHandler? OnAlarmEvent; - public event EventHandler? OnRediscoveryNeeded; - public event EventHandler? OnHostStatusChanged; - - public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) - { - _health = new DriverHealth(DriverState.Initializing, null, null); - try - { - _client = await GalaxyIpcClient.ConnectAsync( - options.PipeName, options.SharedSecret, options.ConnectTimeout, cancellationToken); - - // Route Host-pushed event frames to the matching Raise* methods. Must be set BEFORE - // the first CallAsync so a RuntimeStatusChange arriving between OpenSessionRequest - // and OpenSessionResponse lands on the handler rather than unblocking the call with - // the wrong kind. - _client.SetEventHandler(DispatchHostEventAsync); - - var resp = await _client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = DriverInstanceId, DriverConfigJson = driverConfigJson }, - MessageKind.OpenSessionResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host OpenSession failed: {resp.Error}"); - - _sessionId = resp.SessionId; - _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); - } - catch (Exception ex) - { - _health = new DriverHealth(DriverState.Faulted, null, ex.Message); - throw; - } - } - - public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) - { - await ShutdownAsync(cancellationToken); - await InitializeAsync(driverConfigJson, cancellationToken); - } - - public async Task ShutdownAsync(CancellationToken cancellationToken) - { - if (_client is null) return; - - try - { - await _client.SendOneWayAsync( - MessageKind.CloseSessionRequest, - new CloseSessionRequest { SessionId = _sessionId }, - cancellationToken); - } - catch { /* shutdown is best effort */ } - - await _client.DisposeAsync(); - _client = null; - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); - } - - public DriverHealth GetHealth() => _health; - public long GetMemoryFootprint() => 0; - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - // ---- ITagDiscovery ---- - - public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(builder); - var client = RequireClient(); - - var resp = await client.CallAsync( - MessageKind.DiscoverHierarchyRequest, - new DiscoverHierarchyRequest { SessionId = _sessionId }, - MessageKind.DiscoverHierarchyResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host DiscoverHierarchy failed: {resp.Error}"); - - foreach (var obj in resp.Objects) - { - var folder = builder.Folder(obj.ContainedName, obj.ContainedName); - foreach (var attr in obj.Attributes) - { - var fullName = $"{obj.TagName}.{attr.AttributeName}"; - var handle = folder.Variable( - attr.AttributeName, - attr.AttributeName, - new DriverAttributeInfo( - FullName: fullName, - DriverDataType: MapDataType(attr.MxDataType), - IsArray: attr.IsArray, - ArrayDim: attr.ArrayDim, - SecurityClass: MapSecurity(attr.SecurityClassification), - IsHistorized: attr.IsHistorized, - IsAlarm: attr.IsAlarm)); - - // PR 15: when Galaxy flags the attribute as alarm-bearing (AlarmExtension - // primitive), register an alarm-condition sink so the generic node manager - // can route OnAlarmEvent payloads for this tag to the concrete address-space - // builder. Severity default Medium — the live severity arrives through - // AlarmEventArgs once MxAccessGalaxyBackend's tracker starts firing. - if (attr.IsAlarm) - { - handle.MarkAsAlarmCondition(new AlarmConditionInfo( - SourceName: fullName, - InitialSeverity: AlarmSeverity.Medium, - InitialDescription: null)); - } - } - } - } - - // ---- IReadable ---- - - public async Task> ReadAsync( - IReadOnlyList fullReferences, CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.ReadValuesRequest, - new ReadValuesRequest { SessionId = _sessionId, TagReferences = [.. fullReferences] }, - MessageKind.ReadValuesResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host ReadValues failed: {resp.Error}"); - - var byRef = resp.Values.ToDictionary(v => v.TagReference); - var result = new DataValueSnapshot[fullReferences.Count]; - for (var i = 0; i < fullReferences.Count; i++) - { - result[i] = byRef.TryGetValue(fullReferences[i], out var v) - ? ToSnapshot(v) - : new DataValueSnapshot(null, StatusBadInternalError, null, DateTime.UtcNow); - } - return result; - } - - // ---- IWritable ---- - - public async Task> WriteAsync( - IReadOnlyList writes, CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.WriteValuesRequest, - new WriteValuesRequest - { - SessionId = _sessionId, - Writes = [.. writes.Select(FromWriteRequest)], - }, - MessageKind.WriteValuesResponse, - cancellationToken); - - return [.. resp.Results.Select(r => new WriteResult(r.StatusCode))]; - } - - // ---- ISubscribable ---- - - public async Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.SubscribeRequest, - new SubscribeRequest - { - SessionId = _sessionId, - TagReferences = [.. fullReferences], - RequestedIntervalMs = (int)publishingInterval.TotalMilliseconds, - }, - MessageKind.SubscribeResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host Subscribe failed: {resp.Error}"); - - return new GalaxySubscriptionHandle(resp.SubscriptionId); - } - - public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) - { - var client = RequireClient(); - var sid = ((GalaxySubscriptionHandle)handle).SubscriptionId; - await client.SendOneWayAsync( - MessageKind.UnsubscribeRequest, - new UnsubscribeRequest { SessionId = _sessionId, SubscriptionId = sid }, - cancellationToken); - } - - /// - /// Internal entry point used by the IPC client when the Host pushes an - /// frame. Surfaces it as a managed - /// event. - /// - internal void RaiseDataChange(OnDataChangeNotification notif) - { - var handle = new GalaxySubscriptionHandle(notif.SubscriptionId); - // ISubscribable.OnDataChange fires once per changed attribute — fan out the batch. - foreach (var v in notif.Values) - OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, v.TagReference, ToSnapshot(v))); - } - - // ---- IAlarmSource ---- - - public async Task SubscribeAlarmsAsync( - IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) - { - var client = RequireClient(); - await client.SendOneWayAsync( - MessageKind.AlarmSubscribeRequest, - new AlarmSubscribeRequest { SessionId = _sessionId }, - cancellationToken); - return new GalaxyAlarmSubscriptionHandle($"alarm-{_sessionId}"); - } - - public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) - => Task.CompletedTask; - - public async Task AcknowledgeAsync( - IReadOnlyList acknowledgements, CancellationToken cancellationToken) - { - var client = RequireClient(); - foreach (var ack in acknowledgements) - { - await client.SendOneWayAsync( - MessageKind.AlarmAckRequest, - new AlarmAckRequest - { - SessionId = _sessionId, - EventId = ack.ConditionId, - Comment = ack.Comment ?? string.Empty, - }, - cancellationToken); - } - } - - internal void RaiseAlarmEvent(GalaxyAlarmEvent ev) - { - var handle = new GalaxyAlarmSubscriptionHandle($"alarm-{_sessionId}"); - OnAlarmEvent?.Invoke(this, new AlarmEventArgs( - SubscriptionHandle: handle, - SourceNodeId: ev.ObjectTagName, - ConditionId: ev.EventId, - AlarmType: ev.AlarmName, - Message: ev.Message, - Severity: MapSeverity(ev.Severity), - SourceTimestampUtc: DateTimeOffset.FromUnixTimeMilliseconds(ev.UtcUnixMs).UtcDateTime)); - } - - // ---- IHistoryProvider ---- - - public async Task ReadRawAsync( - string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, - CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.HistoryReadRequest, - new HistoryReadRequest - { - SessionId = _sessionId, - TagReferences = [fullReference], - StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - MaxValuesPerTag = maxValuesPerNode, - }, - MessageKind.HistoryReadResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host HistoryRead failed: {resp.Error}"); - - var first = resp.Tags.FirstOrDefault(); - IReadOnlyList samples = first is null - ? Array.Empty() - : [.. first.Values.Select(ToSnapshot)]; - return new HistoryReadResult(samples, ContinuationPoint: null); - } - - public async Task ReadProcessedAsync( - string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, - HistoryAggregateType aggregate, CancellationToken cancellationToken) - { - var client = RequireClient(); - var column = MapAggregateToColumn(aggregate); - - var resp = await client.CallAsync( - MessageKind.HistoryReadProcessedRequest, - new HistoryReadProcessedRequest - { - SessionId = _sessionId, - TagReference = fullReference, - StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - IntervalMs = (long)interval.TotalMilliseconds, - AggregateColumn = column, - }, - MessageKind.HistoryReadProcessedResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host HistoryReadProcessed failed: {resp.Error}"); - - IReadOnlyList samples = [.. resp.Values.Select(ToSnapshot)]; - return new HistoryReadResult(samples, ContinuationPoint: null); - } - - public async Task ReadAtTimeAsync( - string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.HistoryReadAtTimeRequest, - new HistoryReadAtTimeRequest - { - SessionId = _sessionId, - TagReference = fullReference, - TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())], - }, - MessageKind.HistoryReadAtTimeResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}"); - - // ReadAtTime returns one sample per requested timestamp in the same order — the Host - // pads with bad-quality snapshots when a timestamp can't be interpolated, so response - // length matches request length exactly. We trust that contract rather than - // re-aligning here, because the Host is the source-of-truth for interpolation policy. - IReadOnlyList samples = [.. resp.Values.Select(ToSnapshot)]; - return new HistoryReadResult(samples, ContinuationPoint: null); - } - - public async Task ReadEventsAsync( - string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) - { - var client = RequireClient(); - var resp = await client.CallAsync( - MessageKind.HistoryReadEventsRequest, - new HistoryReadEventsRequest - { - SessionId = _sessionId, - SourceName = sourceName, - StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - MaxEvents = maxEvents, - }, - MessageKind.HistoryReadEventsResponse, - cancellationToken); - - if (!resp.Success) - throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}"); - - IReadOnlyList events = [.. resp.Events.Select(ToHistoricalEvent)]; - return new HistoricalEventsResult(events, ContinuationPoint: null); - } - - internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new( - EventId: wire.EventId, - SourceName: wire.SourceName, - EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime, - ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime, - Message: wire.DisplayText, - Severity: wire.Severity); - - /// - /// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian - /// AnalogSummaryQuery column names consumed by HistorianDataSource.ReadAggregateAsync. - /// Kept on the Proxy side so Galaxy.Host stays OPC-UA-free. - /// - internal static string MapAggregateToColumn(HistoryAggregateType aggregate) => aggregate switch - { - HistoryAggregateType.Average => "Average", - HistoryAggregateType.Minimum => "Minimum", - HistoryAggregateType.Maximum => "Maximum", - HistoryAggregateType.Count => "ValueCount", - HistoryAggregateType.Total => throw new NotSupportedException( - "HistoryAggregateType.Total is not supported by the Wonderware Historian AnalogSummary " + - "query — use Average × Count on the caller side, or switch to Average/Minimum/Maximum/Count."), - _ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"), - }; - - // ---- IRediscoverable ---- - - /// - /// Triggered by the IPC client when the Host pushes a deploy-watermark notification - /// (Galaxy time_of_last_deploy changed per decision #54). - /// - internal void RaiseRediscoveryNeeded(string reason, string? scopeHint = null) => - OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, scopeHint)); - - // ---- IHostConnectivityProbe ---- - - public IReadOnlyList GetHostStatuses() => _hostStatuses; - - internal void OnHostConnectivityUpdate(IpcHostConnectivityStatus update) - { - var translated = new Core.Abstractions.HostConnectivityStatus( - HostName: update.HostName, - State: ParseHostState(update.RuntimeStatus), - LastChangedUtc: DateTimeOffset.FromUnixTimeMilliseconds(update.LastObservedUtcUnixMs).UtcDateTime); - - var prior = _hostStatuses.FirstOrDefault(h => h.HostName == translated.HostName); - _hostStatuses = [ - .. _hostStatuses.Where(h => h.HostName != translated.HostName), - translated - ]; - - if (prior is null || prior.State != translated.State) - { - OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs( - translated.HostName, prior?.State ?? HostState.Unknown, translated.State)); - } - } - - private static HostState ParseHostState(string s) => s switch - { - "Running" => HostState.Running, - "Stopped" => HostState.Stopped, - "Faulted" => HostState.Faulted, - _ => HostState.Unknown, - }; - - // ---- helpers ---- - - /// - /// Event-handler registered with . Decodes - /// the MessagePack body into the matching wire contract and delegates to the existing - /// Raise* helpers. Unknown kinds are silently ignored — the IPC contract is - /// append-only, so a newer Host sending a kind this Proxy doesn't recognise shouldn't - /// break the session. - /// - private Task DispatchHostEventAsync(MessageKind kind, byte[] body) - { - switch (kind) - { - case MessageKind.OnDataChangeNotification: - RaiseDataChange(MessagePackSerializer.Deserialize(body)); - break; - case MessageKind.AlarmEvent: - RaiseAlarmEvent(MessagePackSerializer.Deserialize(body)); - break; - case MessageKind.HostConnectivityStatus: - OnHostConnectivityUpdate(MessagePackSerializer.Deserialize(body)); - break; - case MessageKind.RuntimeStatusChange: - var rsc = MessagePackSerializer.Deserialize(body); - OnHostConnectivityUpdate(rsc.Status); - break; - // HistorianConnectivityStatus has no consumer on this Proxy today — drop. - // Response kinds never reach the event handler; the client routes those to - // their pending CallAsync TCS. - } - return Task.CompletedTask; - } - - private GalaxyIpcClient RequireClient() => - _client ?? throw new InvalidOperationException("Driver not initialized"); - - private const uint StatusBadInternalError = 0x80020000u; - - private static DataValueSnapshot ToSnapshot(GalaxyDataValue v) => new( - Value: v.ValueBytes, - StatusCode: v.StatusCode, - SourceTimestampUtc: v.SourceTimestampUtcUnixMs > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(v.SourceTimestampUtcUnixMs).UtcDateTime - : null, - ServerTimestampUtc: DateTimeOffset.FromUnixTimeMilliseconds(v.ServerTimestampUtcUnixMs).UtcDateTime); - - private static GalaxyDataValue FromWriteRequest(WriteRequest w) => new() - { - TagReference = w.FullReference, - ValueBytes = MessagePack.MessagePackSerializer.Serialize(w.Value), - ValueMessagePackType = 0, - StatusCode = 0, - SourceTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - - private static DriverDataType MapDataType(int mxDataType) => mxDataType switch - { - 0 => DriverDataType.Boolean, - 1 => DriverDataType.Int32, - 2 => DriverDataType.Float32, - 3 => DriverDataType.Float64, - 4 => DriverDataType.String, - 5 => DriverDataType.DateTime, - _ => DriverDataType.String, - }; - - private static SecurityClassification MapSecurity(int mxSec) => mxSec switch - { - 0 => SecurityClassification.FreeAccess, - 1 => SecurityClassification.Operate, - 2 => SecurityClassification.SecuredWrite, - 3 => SecurityClassification.VerifiedWrite, - 4 => SecurityClassification.Tune, - 5 => SecurityClassification.Configure, - 6 => SecurityClassification.ViewOnly, - _ => SecurityClassification.FreeAccess, - }; - - private static AlarmSeverity MapSeverity(int sev) => sev switch - { - <= 250 => AlarmSeverity.Low, - <= 500 => AlarmSeverity.Medium, - <= 800 => AlarmSeverity.High, - _ => AlarmSeverity.Critical, - }; - - /// - /// Phase 7 follow-up #247 — IAlarmHistorianWriter implementation. Forwards alarm - /// batches to Galaxy.Host over the existing IPC channel, reusing the connection - /// the driver already established for data-plane traffic. Throws - /// when called before - /// has connected the client; the SQLite drain worker - /// translates that to whole-batch RetryPlease per its catch contract. - /// - public Task> WriteBatchAsync( - IReadOnlyList batch, CancellationToken cancellationToken) - { - if (_client is null) - throw new InvalidOperationException( - "GalaxyProxyDriver IPC client not connected — historian writes rejected until InitializeAsync completes"); - return new GalaxyHistorianWriter(_client).WriteBatchAsync(batch, cancellationToken); - } - - public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult(); -} - -internal sealed record GalaxySubscriptionHandle(long SubscriptionId) : ISubscriptionHandle -{ - public string DiagnosticId => $"galaxy-sub-{SubscriptionId}"; -} - -internal sealed record GalaxyAlarmSubscriptionHandle(string Id) : IAlarmSubscriptionHandle -{ - public string DiagnosticId => Id; -} - -public sealed class GalaxyProxyOptions -{ - public required string DriverInstanceId { get; init; } - public required string PipeName { get; init; } - public required string SharedSecret { get; init; } - public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs deleted file mode 100644 index 073a92f..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Text.Json; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Hosting; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; - -/// -/// Static factory registration helper for . Server's -/// Program.cs calls once at startup; the bootstrapper (task #248) -/// then materialises Galaxy DriverInstance rows from the central config DB into live -/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the -/// driver project stays free of DI machinery. -/// -public static class GalaxyProxyDriverFactoryExtensions -{ - public const string DriverTypeName = "Galaxy"; - - /// - /// Register the Galaxy driver factory in the supplied . - /// Throws if 'Galaxy' is already registered — single-instance per process. - /// - public static void Register(DriverFactoryRegistry registry) - { - ArgumentNullException.ThrowIfNull(registry); - // Galaxy is Tier C — out-of-process MXAccess Host, scheduled recycle is allowed. - registry.Register(DriverTypeName, CreateInstance, DriverTier.C); - } - - internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) - { - ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); - ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); - - // DriverConfig column is a JSON object that mirrors GalaxyProxyOptions. - // Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s). - // The DriverInstanceId from the row wins over any value in the JSON — the row - // is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId. - using var doc = JsonDocument.Parse(driverConfigJson); - var root = doc.RootElement; - - string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String - ? p.GetString()! - : throw new InvalidOperationException( - $"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName"); - string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String - ? s.GetString()! - : throw new InvalidOperationException( - $"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret"); - var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number - ? TimeSpan.FromMilliseconds(t.GetInt32()) - : TimeSpan.FromSeconds(10); - - return new GalaxyProxyDriver(new GalaxyProxyOptions - { - DriverInstanceId = driverInstanceId, - PipeName = pipeName, - SharedSecret = sharedSecret, - ConnectTimeout = connectTimeout, - }); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs deleted file mode 100644 index a535e43..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs +++ /dev/null @@ -1,90 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; - -/// -/// Phase 7 follow-up (task #247) — bridges 's -/// drain worker to Driver.Galaxy.Host over the existing -/// pipe. Translates batches into the -/// wire format the Host expects + maps per-event -/// responses back to -/// so the SQLite queue knows what to ack / -/// dead-letter / retry. -/// -/// -/// -/// Reuses the IPC channel already opens for the -/// Galaxy data plane — no second pipe to Driver.Galaxy.Host, no separate -/// auth handshake. The IPC client's call gate serializes historian batches with -/// driver Reads/Writes/Subscribes; historian batches are infrequent (every few -/// seconds at most under the SQLite sink's drain cadence) so the contention is -/// negligible compared to per-tag-read pressure. -/// -/// -/// Pipe-level transport faults (broken pipe, host crash) bubble up as -/// which the SQLite sink's drain worker catches + -/// translates to a whole-batch RetryPlease per the -/// docstring — failed events stay queued -/// for the next drain tick after backoff. -/// -/// -public sealed class GalaxyHistorianWriter : IAlarmHistorianWriter -{ - private readonly GalaxyIpcClient _client; - - public GalaxyHistorianWriter(GalaxyIpcClient client) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - } - - public async Task> WriteBatchAsync( - IReadOnlyList batch, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(batch); - if (batch.Count == 0) return []; - - var request = new HistorianAlarmEventRequest - { - Events = batch.Select(ToDto).ToArray(), - }; - - var response = await _client.CallAsync( - requestKind: MessageKind.HistorianAlarmEventRequest, - request: request, - expectedResponseKind: MessageKind.HistorianAlarmEventResponse, - ct: cancellationToken).ConfigureAwait(false); - - if (response.Outcomes.Length != batch.Count) - throw new InvalidOperationException( - $"Galaxy.Host returned {response.Outcomes.Length} outcomes for a batch of {batch.Count} — protocol mismatch"); - - var outcomes = new HistorianWriteOutcome[response.Outcomes.Length]; - for (var i = 0; i < response.Outcomes.Length; i++) - outcomes[i] = MapOutcome(response.Outcomes[i]); - return outcomes; - } - - internal static HistorianAlarmEventDto ToDto(AlarmHistorianEvent e) => new() - { - AlarmId = e.AlarmId, - EquipmentPath = e.EquipmentPath, - AlarmName = e.AlarmName, - AlarmTypeName = e.AlarmTypeName, - Severity = (int)e.Severity, - EventKind = e.EventKind, - Message = e.Message, - User = e.User, - Comment = e.Comment, - TimestampUtcUnixMs = new DateTimeOffset(e.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), - }; - - internal static HistorianWriteOutcome MapOutcome(HistorianAlarmEventOutcomeDto wire) => wire switch - { - HistorianAlarmEventOutcomeDto.Ack => HistorianWriteOutcome.Ack, - HistorianAlarmEventOutcomeDto.RetryPlease => HistorianWriteOutcome.RetryPlease, - HistorianAlarmEventOutcomeDto.PermanentFail => HistorianWriteOutcome.PermanentFail, - _ => throw new InvalidOperationException($"Unknown HistorianAlarmEventOutcomeDto byte {(byte)wire}"), - }; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs deleted file mode 100644 index 649aa1a..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.IO.Pipes; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; - -/// -/// Client-side IPC channel to a running Driver.Galaxy.Host. Owns the data-plane pipe -/// connection, serializes request/response round-trips, and routes unsolicited push frames -/// (, , -/// , , -/// ) to a handler supplied via -/// . One instance per session. -/// -/// -/// A single background reader task owns the read side of the pipe. Calls are serialized by -/// , so at most one pending response is outstanding at a time — the -/// reader uses a single pending-response slot. Any frame that doesn't match the pending -/// expected kind (or ) is treated as a push event and -/// forwarded to the registered handler. Without this router, a push event arriving between -/// request and response would satisfy the caller's read and fail the next -/// with an "Expected X, got Y" error. -/// -public sealed class GalaxyIpcClient : IAsyncDisposable -{ - private readonly NamedPipeClientStream _stream; - private readonly FrameReader _reader; - private readonly FrameWriter _writer; - private readonly SemaphoreSlim _writeGate = new(1, 1); - private readonly CancellationTokenSource _readerCts = new(); - - private readonly object _pendingLock = new(); - private TaskCompletionSource<(MessageKind Kind, byte[] Body)>? _pending; - private MessageKind _pendingExpected; - - private Task? _readerTask; - private Func? _eventHandler; - - private GalaxyIpcClient(NamedPipeClientStream stream) - { - _stream = stream; - _reader = new FrameReader(stream, leaveOpen: true); - _writer = new FrameWriter(stream, leaveOpen: true); - } - - /// Connects, sends Hello with the shared secret, and awaits HelloAck. Throws on rejection. - public static async Task ConnectAsync( - string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct) - { - var stream = new NamedPipeClientStream( - serverName: ".", - pipeName: pipeName, - direction: PipeDirection.InOut, - options: PipeOptions.Asynchronous); - - await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct); - - var client = new GalaxyIpcClient(stream); - try - { - await client._writer.WriteAsync(MessageKind.Hello, - new Hello { PeerName = "Galaxy.Proxy", SharedSecret = sharedSecret }, ct); - - // Hello/HelloAck is the one round-trip that runs inline before the reader loop - // starts — the Host expects its response-side write before accepting any other - // frames, so there's no push-event window to worry about here. - var ack = await client._reader.ReadFrameAsync(ct); - if (ack is null || ack.Value.Kind != MessageKind.HelloAck) - throw new InvalidOperationException("Did not receive HelloAck from Galaxy.Host"); - - var ackMsg = FrameReader.Deserialize(ack.Value.Body); - if (!ackMsg.Accepted) - throw new UnauthorizedAccessException($"Galaxy.Host rejected Hello: {ackMsg.RejectReason}"); - - client._readerTask = Task.Run(() => client.ReadLoopAsync(client._readerCts.Token)); - return client; - } - catch - { - await client.DisposeAsync(); - throw; - } - } - - /// - /// Register a handler that receives unsolicited push frames. Safe to call once per - /// session — typically during the driver's InitializeAsync right after - /// . The handler is invoked on the reader's thread-pool - /// task; it should not block. Exceptions thrown by the handler are swallowed so a - /// buggy event subscriber cannot kill the reader loop. - /// - public void SetEventHandler(Func handler) - => _eventHandler = handler ?? throw new ArgumentNullException(nameof(handler)); - - /// Round-trips a request and returns the deserialized response. - public async Task CallAsync( - MessageKind requestKind, TReq request, MessageKind expectedResponseKind, CancellationToken ct) - { - await _writeGate.WaitAsync(ct); - var tcs = new TaskCompletionSource<(MessageKind, byte[])>( - TaskCreationOptions.RunContinuationsAsynchronously); - try - { - lock (_pendingLock) - { - if (_pending is not null) - throw new InvalidOperationException( - "GalaxyIpcClient pending-response slot is not empty — call re-entry is a bug"); - _pending = tcs; - _pendingExpected = expectedResponseKind; - } - - await _writer.WriteAsync(requestKind, request, ct); - - using var reg = ct.Register(static s => - ((TaskCompletionSource<(MessageKind, byte[])>)s!).TrySetCanceled(), tcs); - var frame = await tcs.Task.ConfigureAwait(false); - - if (frame.Item1 == MessageKind.ErrorResponse) - { - var err = MessagePackSerializer.Deserialize(frame.Item2); - throw new GalaxyIpcException(err.Code, err.Message); - } - - return MessagePackSerializer.Deserialize(frame.Item2); - } - finally - { - lock (_pendingLock) - { - if (ReferenceEquals(_pending, tcs)) _pending = null; - } - _writeGate.Release(); - } - } - - /// - /// Fire-and-forget request — used for unsubscribe, alarm-ack, close-session, and other - /// calls where the protocol is one-way. The send is still serialized through the write - /// gate so it doesn't interleave a frame with a concurrent . - /// - public async Task SendOneWayAsync(MessageKind requestKind, TReq request, CancellationToken ct) - { - await _writeGate.WaitAsync(ct); - try { await _writer.WriteAsync(requestKind, request, ct); } - finally { _writeGate.Release(); } - } - - private async Task ReadLoopAsync(CancellationToken ct) - { - try - { - while (!ct.IsCancellationRequested) - { - (MessageKind Kind, byte[] Body)? frame; - try - { - var read = await _reader.ReadFrameAsync(ct).ConfigureAwait(false); - frame = read is null ? null : (read.Value.Kind, read.Value.Body); - } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - FailPending(ex); - break; - } - - if (frame is null) - { - FailPending(new EndOfStreamException("IPC peer closed the pipe")); - break; - } - - // Route: response-ish frame to pending TCS if one is waiting, else treat as event. - // ErrorResponse always terminates a pending call — that's the Host signalling a - // request-scoped failure. Unsolicited ErrorResponse with no pending call shouldn't - // happen under a well-formed protocol; if it does, we drop it to the event channel - // so it shows up in logs rather than deadlocking the next CallAsync. - TaskCompletionSource<(MessageKind, byte[])>? pendingTcs = null; - lock (_pendingLock) - { - if (_pending is not null && (frame.Value.Kind == _pendingExpected - || frame.Value.Kind == MessageKind.ErrorResponse)) - { - pendingTcs = _pending; - _pending = null; - } - } - - if (pendingTcs is not null) - { - pendingTcs.TrySetResult(frame.Value); - continue; - } - - var handler = _eventHandler; - if (handler is null) continue; - - try { await handler(frame.Value.Kind, frame.Value.Body).ConfigureAwait(false); } - catch - { - // A buggy subscriber must not kill the reader. The handler is expected to - // do its own logging; swallowing here keeps the channel alive for the next - // frame + the next CallAsync. - } - } - } - finally - { - // Any still-pending call after the loop exits would otherwise hang forever. - FailPending(new EndOfStreamException("IPC reader loop exited")); - } - } - - private void FailPending(Exception ex) - { - TaskCompletionSource<(MessageKind, byte[])>? tcs; - lock (_pendingLock) { tcs = _pending; _pending = null; } - tcs?.TrySetException(ex); - } - - public async ValueTask DisposeAsync() - { - _readerCts.Cancel(); - if (_readerTask is not null) - { - try { await _readerTask.ConfigureAwait(false); } catch { /* shutdown */ } - } - - _writeGate.Dispose(); - _reader.Dispose(); - _writer.Dispose(); - _readerCts.Dispose(); - await _stream.DisposeAsync(); - } -} - -public sealed class GalaxyIpcException(string code, string message) - : Exception($"[{code}] {message}") -{ - public string Code { get; } = code; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs deleted file mode 100644 index 785c458..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -/// -/// Respawn-with-backoff schedule per driver-stability.md §"Crash-loop circuit breaker": -/// 5s → 15s → 60s, capped. Reset on a successful (> ) -/// run. -/// -public sealed class Backoff -{ - public static TimeSpan[] DefaultSequence { get; } = - [TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)]; - - public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2); - - private readonly TimeSpan[] _sequence; - private int _index; - - public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence; - - public TimeSpan Next() - { - var delay = _sequence[Math.Min(_index, _sequence.Length - 1)]; - _index++; - return delay; - } - - /// Called when the spawned process has stayed up past the stable threshold. - public void RecordStableRun() => _index = 0; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs deleted file mode 100644 index 7f391af..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -/// -/// Crash-loop circuit breaker per driver-stability.md: -/// 3 crashes within 5 min → open with escalating cooldown 1h → 4h → 24h manual. A sticky -/// alert stays until the operator explicitly resets. -/// -public sealed class CircuitBreaker -{ - public int CrashesAllowedPerWindow { get; init; } = 3; - public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5); - - public TimeSpan[] CooldownEscalation { get; init; } = - [TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue]; - - private readonly List _crashesUtc = []; - private DateTime? _openSinceUtc; - private int _escalationLevel; - public bool StickyAlertActive { get; private set; } - - /// - /// Called by the supervisor each time the host process exits unexpectedly. Returns - /// false when the breaker is open — supervisor must not respawn. - /// - public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining) - { - if (_openSinceUtc is { } openedAt) - { - var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)]; - if (cooldown == TimeSpan.MaxValue) - { - cooldownRemaining = TimeSpan.MaxValue; - return false; // manual reset required - } - if (utcNow - openedAt < cooldown) - { - cooldownRemaining = cooldown - (utcNow - openedAt); - return false; - } - - // Cooldown elapsed — close the breaker but keep the sticky alert per spec. - _openSinceUtc = null; - _escalationLevel++; - } - - _crashesUtc.RemoveAll(t => utcNow - t > Window); - _crashesUtc.Add(utcNow); - - if (_crashesUtc.Count > CrashesAllowedPerWindow) - { - _openSinceUtc = utcNow; - StickyAlertActive = true; - cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)]; - return false; - } - - cooldownRemaining = TimeSpan.Zero; - return true; - } - - public void ManualReset() - { - _crashesUtc.Clear(); - _openSinceUtc = null; - _escalationLevel = 0; - StickyAlertActive = false; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs deleted file mode 100644 index f4bee22..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -/// -/// Tracks missed heartbeats on the dedicated heartbeat pipe per -/// driver-stability.md §"Heartbeat between proxy and host": 2s cadence, 3 consecutive -/// misses = host declared dead (~6s detection). -/// -public sealed class HeartbeatMonitor -{ - public int MissesUntilDead { get; init; } = 3; - - public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2); - - public int ConsecutiveMisses { get; private set; } - public DateTime? LastAckUtc { get; private set; } - - public void RecordAck(DateTime utcNow) - { - ConsecutiveMisses = 0; - LastAckUtc = utcNow; - } - - public bool RecordMiss() - { - ConsecutiveMisses++; - return ConsecutiveMisses >= MissesUntilDead; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj deleted file mode 100644 index be33f47..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - enable - enable - latest - true - true - $(NoWarn);CS1591 - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy - - - - - - - - - - - - - - - - - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs deleted file mode 100644 index caafacb..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -[MessagePackObject] -public sealed class AlarmSubscribeRequest -{ - [Key(0)] public long SessionId { get; set; } -} - -[MessagePackObject] -public sealed class GalaxyAlarmEvent -{ - [Key(0)] public string EventId { get; set; } = string.Empty; - [Key(1)] public string ObjectTagName { get; set; } = string.Empty; - [Key(2)] public string AlarmName { get; set; } = string.Empty; - [Key(3)] public int Severity { get; set; } - - /// Per OPC UA Part 9 lifecycle: Active, Unacknowledged, Confirmed, Inactive, etc. - [Key(4)] public string StateTransition { get; set; } = string.Empty; - - [Key(5)] public string Message { get; set; } = string.Empty; - [Key(6)] public long UtcUnixMs { get; set; } -} - -[MessagePackObject] -public sealed class AlarmAckRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string EventId { get; set; } = string.Empty; - [Key(2)] public string Comment { get; set; } = string.Empty; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs deleted file mode 100644 index 8a2ce92..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs +++ /dev/null @@ -1,53 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -/// -/// IPC-shape for a tag value snapshot. Per decision #13: value + StatusCode + source + server timestamps. -/// -[MessagePackObject] -public sealed class GalaxyDataValue -{ - [Key(0)] public string TagReference { get; set; } = string.Empty; - [Key(1)] public byte[]? ValueBytes { get; set; } - [Key(2)] public int ValueMessagePackType { get; set; } - [Key(3)] public uint StatusCode { get; set; } - [Key(4)] public long SourceTimestampUtcUnixMs { get; set; } - [Key(5)] public long ServerTimestampUtcUnixMs { get; set; } -} - -[MessagePackObject] -public sealed class ReadValuesRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class ReadValuesResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class WriteValuesRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public GalaxyDataValue[] Writes { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class WriteValueResult -{ - [Key(0)] public string TagReference { get; set; } = string.Empty; - [Key(1)] public uint StatusCode { get; set; } - [Key(2)] public string? Error { get; set; } -} - -[MessagePackObject] -public sealed class WriteValuesResponse -{ - [Key(0)] public WriteValueResult[] Results { get; set; } = System.Array.Empty(); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs deleted file mode 100644 index 1092707..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs +++ /dev/null @@ -1,50 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -[MessagePackObject] -public sealed class DiscoverHierarchyRequest -{ - [Key(0)] public long SessionId { get; set; } -} - -/// -/// IPC-shape for a Galaxy object. Proxy maps to/from DriverAttributeInfo (Core.Abstractions). -/// -[MessagePackObject] -public sealed class GalaxyObjectInfo -{ - [Key(0)] public string ContainedName { get; set; } = string.Empty; - [Key(1)] public string TagName { get; set; } = string.Empty; - [Key(2)] public string? ParentContainedName { get; set; } - [Key(3)] public string TemplateCategory { get; set; } = string.Empty; - [Key(4)] public GalaxyAttributeInfo[] Attributes { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class GalaxyAttributeInfo -{ - [Key(0)] public string AttributeName { get; set; } = string.Empty; - [Key(1)] public int MxDataType { get; set; } - [Key(2)] public bool IsArray { get; set; } - [Key(3)] public uint? ArrayDim { get; set; } - [Key(4)] public int SecurityClassification { get; set; } - [Key(5)] public bool IsHistorized { get; set; } - - /// - /// True when the attribute has an AlarmExtension primitive in the Galaxy repository - /// (primitive_definition.primitive_name = 'AlarmExtension'). The generic - /// node-manager uses this to enrich the variable's OPC UA node with an - /// AlarmConditionState during address-space build. Added in PR 9 as the - /// discovery-side foundation for the alarm event wire-up that follows in PR 10+. - /// - [Key(6)] public bool IsAlarm { get; set; } -} - -[MessagePackObject] -public sealed class DiscoverHierarchyResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public GalaxyObjectInfo[] Objects { get; set; } = System.Array.Empty(); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs deleted file mode 100644 index 193d771..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -/// -/// Length-prefixed framing per decision #28. Each IPC frame 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. -/// -public static class Framing -{ - public const int LengthPrefixSize = 4; - public const int KindByteSize = 1; - - /// - /// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or - /// misbehaving peer sending an oversized length prefix. - /// - public const int MaxFrameBodyBytes = 16 * 1024 * 1024; -} - -/// -/// Wire identifier for each contract. Values are stable — new contracts append. -/// -public enum MessageKind : byte -{ - Hello = 0x01, - HelloAck = 0x02, - Heartbeat = 0x03, - HeartbeatAck = 0x04, - - OpenSessionRequest = 0x10, - OpenSessionResponse = 0x11, - CloseSessionRequest = 0x12, - - DiscoverHierarchyRequest = 0x20, - DiscoverHierarchyResponse = 0x21, - - ReadValuesRequest = 0x30, - ReadValuesResponse = 0x31, - WriteValuesRequest = 0x32, - WriteValuesResponse = 0x33, - - SubscribeRequest = 0x40, - SubscribeResponse = 0x41, - UnsubscribeRequest = 0x42, - OnDataChangeNotification = 0x43, - - AlarmSubscribeRequest = 0x50, - AlarmEvent = 0x51, - AlarmAckRequest = 0x52, - - HistoryReadRequest = 0x60, - HistoryReadResponse = 0x61, - HistoryReadProcessedRequest = 0x62, - HistoryReadProcessedResponse = 0x63, - HistoryReadAtTimeRequest = 0x64, - HistoryReadAtTimeResponse = 0x65, - HistoryReadEventsRequest = 0x66, - HistoryReadEventsResponse = 0x67, - - HostConnectivityStatus = 0x70, - RuntimeStatusChange = 0x71, - - // Phase 7 Stream D — historian alarm sink. Main server → Galaxy.Host batched - // writes into the Aveva Historian alarm schema via the already-loaded - // aahClientManaged DLLs. HistorianConnectivityStatus fires proactively from the - // Host when the SDK session transitions so diagnostics flip promptly. - HistorianAlarmEventRequest = 0x80, - HistorianAlarmEventResponse = 0x81, - HistorianConnectivityStatus = 0x82, - - RecycleHostRequest = 0xF0, - RecycleStatusResponse = 0xF1, - - ErrorResponse = 0xFE, -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs deleted file mode 100644 index 1077356..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -/// -/// First frame of every connection. Advertises protocol major/minor and the peer's feature set. -/// Major mismatch is fatal; minor is advisory. Per Task A.3. -/// -[MessagePackObject] -public sealed class Hello -{ - public const int CurrentMajor = 1; - public const int CurrentMinor = 0; - - [Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor; - [Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor; - [Key(2)] public string PeerName { get; set; } = string.Empty; - - /// Per-process shared secret — verified on the Host side against the value passed by the supervisor at spawn time. - [Key(3)] public string SharedSecret { get; set; } = string.Empty; - - [Key(4)] public string[] Features { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class HelloAck -{ - [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor; - [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor; - - /// True if the server accepted the hello; false + filled if not. - [Key(2)] public bool Accepted { get; set; } - [Key(3)] public string? RejectReason { get; set; } - - [Key(4)] public string HostName { get; set; } = string.Empty; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs deleted file mode 100644 index 6719cdd..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -/// -/// Phase 7 Stream D — IPC contracts for routing Part 9 alarm transitions from the -/// main .NET 10 server into Galaxy.Host's already-loaded aahClientManaged -/// DLLs. Reuses the Tier-C isolation + licensing pathway rather than loading 32-bit -/// native historian code into the main server. -/// -/// -/// -/// Batched on the wire to amortize IPC overhead — the main server's SqliteStoreAndForwardSink -/// ships up to 100 events per request per Phase 7 plan Stream D.5. -/// -/// -/// Per-event outcomes (Ack / RetryPlease / PermanentFail) let the drain worker -/// dead-letter malformed events without blocking neighbors in the batch. -/// fires proactively from -/// the Host when the SDK session drops so the /hosts + /alarms/historian Admin -/// diagnostics pages flip to red promptly instead of waiting for the next -/// drain cycle. -/// -/// -[MessagePackObject] -public sealed class HistorianAlarmEventRequest -{ - [Key(0)] public HistorianAlarmEventDto[] Events { get; set; } = Array.Empty(); -} - -[MessagePackObject] -public sealed class HistorianAlarmEventResponse -{ - /// Per-event outcome, same order as the request. - [Key(0)] public HistorianAlarmEventOutcomeDto[] Outcomes { get; set; } = Array.Empty(); -} - -/// Outcome enum — bytes on the wire so it stays compact. -public enum HistorianAlarmEventOutcomeDto : byte -{ - /// Successfully persisted to the historian — remove from queue. - Ack = 0, - /// Transient failure (historian disconnected, timeout, busy) — retry after backoff. - RetryPlease = 1, - /// Permanent failure (malformed, unrecoverable SDK error) — move to dead-letter. - PermanentFail = 2, -} - -/// One alarm-transition payload. Fields mirror Core.AlarmHistorian.AlarmHistorianEvent. -[MessagePackObject] -public sealed class HistorianAlarmEventDto -{ - [Key(0)] public string AlarmId { get; set; } = string.Empty; - [Key(1)] public string EquipmentPath { get; set; } = string.Empty; - [Key(2)] public string AlarmName { get; set; } = string.Empty; - - /// Concrete Part 9 subtype name — "LimitAlarm" / "OffNormalAlarm" / "AlarmCondition" / "DiscreteAlarm". - [Key(3)] public string AlarmTypeName { get; set; } = string.Empty; - - /// Numeric severity the Host maps to the historian's priority scale. - [Key(4)] public int Severity { get; set; } - - /// Which transition this event represents — "Activated" / "Cleared" / "Acknowledged" / etc. - [Key(5)] public string EventKind { get; set; } = string.Empty; - - /// Pre-rendered message — template tokens resolved upstream. - [Key(6)] public string Message { get; set; } = string.Empty; - - /// Operator who triggered the transition. "system" for engine-driven events. - [Key(7)] public string User { get; set; } = "system"; - - /// Operator-supplied free-form comment, if any. - [Key(8)] public string? Comment { get; set; } - - /// Source timestamp (UTC Unix milliseconds). - [Key(9)] public long TimestampUtcUnixMs { get; set; } -} - -/// -/// Proactive notification — Galaxy.Host pushes this when the historian SDK session -/// transitions (connected / disconnected / degraded). The main server reflects this -/// into the historian sink status so Admin UI surfaces the problem without the -/// operator having to scrutinize drain cadence. -/// -[MessagePackObject] -public sealed class HistorianConnectivityStatusNotification -{ - [Key(0)] public string Status { get; set; } = "unknown"; // connected | disconnected | degraded - [Key(1)] public string? Detail { get; set; } - [Key(2)] public long ObservedAtUtcUnixMs { get; set; } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs deleted file mode 100644 index 4a7f16e..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs +++ /dev/null @@ -1,110 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -[MessagePackObject] -public sealed class HistoryReadRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty(); - [Key(2)] public long StartUtcUnixMs { get; set; } - [Key(3)] public long EndUtcUnixMs { get; set; } - [Key(4)] public uint MaxValuesPerTag { get; set; } = 1000; -} - -[MessagePackObject] -public sealed class HistoryTagValues -{ - [Key(0)] public string TagReference { get; set; } = string.Empty; - [Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class HistoryReadResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty(); -} - -/// -/// Processed (aggregated) historian read — OPC UA HistoryReadProcessed service. The -/// aggregate column is a string (e.g. "Average", "Minimum") mapped by the Proxy from the -/// OPC UA HistoryAggregateType enum so Galaxy.Host stays OPC-UA-free. -/// -[MessagePackObject] -public sealed class HistoryReadProcessedRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string TagReference { get; set; } = string.Empty; - [Key(2)] public long StartUtcUnixMs { get; set; } - [Key(3)] public long EndUtcUnixMs { get; set; } - [Key(4)] public long IntervalMs { get; set; } - [Key(5)] public string AggregateColumn { get; set; } = "Average"; -} - -[MessagePackObject] -public sealed class HistoryReadProcessedResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); -} - -/// -/// At-time historian read — OPC UA HistoryReadAtTime service. Returns one sample per -/// requested timestamp (interpolated when no exact match exists). The per-timestamp array -/// is flow-encoded as Unix milliseconds to avoid MessagePack DateTime quirks. -/// -[MessagePackObject] -public sealed class HistoryReadAtTimeRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string TagReference { get; set; } = string.Empty; - [Key(2)] public long[] TimestampsUtcUnixMs { get; set; } = System.Array.Empty(); -} - -[MessagePackObject] -public sealed class HistoryReadAtTimeResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); -} - -/// -/// Historical events read — OPC UA HistoryReadEvents service and Alarm & Condition -/// history. SourceName null means "all sources". Distinct from the live -/// stream because historical rows carry both -/// EventTime (when the event occurred in the process) and ReceivedTime -/// (when the Historian persisted it) and have no StateTransition — the Historian logs -/// the instantaneous event, not the OPC UA alarm lifecycle. -/// -[MessagePackObject] -public sealed class HistoryReadEventsRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string? SourceName { get; set; } - [Key(2)] public long StartUtcUnixMs { get; set; } - [Key(3)] public long EndUtcUnixMs { get; set; } - [Key(4)] public int MaxEvents { get; set; } = 1000; -} - -[MessagePackObject] -public sealed class GalaxyHistoricalEvent -{ - [Key(0)] public string EventId { get; set; } = string.Empty; - [Key(1)] public string? SourceName { get; set; } - [Key(2)] public long EventTimeUtcUnixMs { get; set; } - [Key(3)] public long ReceivedTimeUtcUnixMs { get; set; } - [Key(4)] public string? DisplayText { get; set; } - [Key(5)] public ushort Severity { get; set; } -} - -[MessagePackObject] -public sealed class HistoryReadEventsResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public GalaxyHistoricalEvent[] Events { get; set; } = System.Array.Empty(); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs deleted file mode 100644 index 1ecc6f0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs +++ /dev/null @@ -1,47 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -[MessagePackObject] -public sealed class OpenSessionRequest -{ - [Key(0)] public string DriverInstanceId { get; set; } = string.Empty; - - /// JSON blob sourced from DriverInstance.DriverConfig. - [Key(1)] public string DriverConfigJson { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class OpenSessionResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public long SessionId { get; set; } -} - -[MessagePackObject] -public sealed class CloseSessionRequest -{ - [Key(0)] public long SessionId { get; set; } -} - -[MessagePackObject] -public sealed class Heartbeat -{ - [Key(0)] public long SequenceNumber { get; set; } - [Key(1)] public long UtcUnixMs { get; set; } -} - -[MessagePackObject] -public sealed class HeartbeatAck -{ - [Key(0)] public long SequenceNumber { get; set; } - [Key(1)] public long UtcUnixMs { get; set; } -} - -[MessagePackObject] -public sealed class ErrorResponse -{ - [Key(0)] public string Code { get; set; } = string.Empty; - [Key(1)] public string Message { get; set; } = string.Empty; -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs deleted file mode 100644 index 2f0a3bc..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -/// Per-host runtime status — per driver-stability.md Galaxy §"Connection Health Probe". -[MessagePackObject] -public sealed class HostConnectivityStatus -{ - [Key(0)] public string HostName { get; set; } = string.Empty; - [Key(1)] public string RuntimeStatus { get; set; } = string.Empty; // Running | Stopped | Unknown - [Key(2)] public long LastObservedUtcUnixMs { get; set; } -} - -[MessagePackObject] -public sealed class RuntimeStatusChangeNotification -{ - [Key(0)] public HostConnectivityStatus Status { get; set; } = new(); -} - -[MessagePackObject] -public sealed class RecycleHostRequest -{ - /// One of: Soft, Hard. - [Key(0)] public string Kind { get; set; } = "Soft"; - [Key(1)] public string Reason { get; set; } = string.Empty; -} - -[MessagePackObject] -public sealed class RecycleStatusResponse -{ - [Key(0)] public bool Accepted { get; set; } - [Key(1)] public int GraceSeconds { get; set; } = 15; - [Key(2)] public string? Error { get; set; } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs deleted file mode 100644 index f655755..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MessagePack; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -[MessagePackObject] -public sealed class SubscribeRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty(); - [Key(2)] public int RequestedIntervalMs { get; set; } = 1000; -} - -[MessagePackObject] -public sealed class SubscribeResponse -{ - [Key(0)] public bool Success { get; set; } - [Key(1)] public string? Error { get; set; } - [Key(2)] public long SubscriptionId { get; set; } - [Key(3)] public int ActualIntervalMs { get; set; } -} - -[MessagePackObject] -public sealed class UnsubscribeRequest -{ - [Key(0)] public long SessionId { get; set; } - [Key(1)] public long SubscriptionId { get; set; } -} - -[MessagePackObject] -public sealed class OnDataChangeNotification -{ - [Key(0)] public long SubscriptionId { get; set; } - [Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty(); -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs deleted file mode 100644 index 45c476c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; - -/// -/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call -/// from multiple threads against the same instance. -/// -public sealed class FrameReader : IDisposable -{ - private readonly Stream _stream; - private readonly bool _leaveOpen; - - public FrameReader(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - 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($"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); - } - - 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; - } - - public void Dispose() - { - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs deleted file mode 100644 index f0b34f9..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; - -/// -/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via -/// — multiple producers (e.g. heartbeat + data-plane sharing a stream) -/// get serialized writes. -/// -public sealed class FrameWriter : IDisposable -{ - private readonly Stream _stream; - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly bool _leaveOpen; - - public FrameWriter(Stream stream, bool leaveOpen = false) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - _leaveOpen = leaveOpen; - } - - 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( - $"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(); } - } - - public void Dispose() - { - _gate.Dispose(); - if (!_leaveOpen) _stream.Dispose(); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj deleted file mode 100644 index dada37e..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - netstandard2.0 - enable - latest - true - true - $(NoWarn);CS1591 - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared - - - - - - - - - - - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index ec8da46..2f87dcf 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -14,7 +14,6 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.S7; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; @@ -110,12 +109,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => { var registry = new DriverFactoryRegistry(); - // Both Galaxy backends register side-by-side under distinct DriverType names - // ("Galaxy" → legacy GalaxyProxyDriver, "GalaxyMxGateway" → in-process GalaxyDriver - // over the gRPC mxaccessgw). The DriverInstance row's DriverType selects between - // them at bootstrap time — see lmx_mxgw.md / PR 4.W. Phase 7 retires the legacy - // factory once parity tests pin both. - GalaxyProxyDriverFactoryExtensions.Register(registry); + // Galaxy access flows through the in-process GalaxyDriver (DriverType = + // "GalaxyMxGateway") talking gRPC to the mxaccessgw worker. The legacy + // out-of-process GalaxyProxyDriver retired in PR 7.2 once the parity matrix + // (docs/v2/Galaxy.ParityMatrix.md) verified equivalence. ZB.MOM.WW.OtOpcUa.Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry); FocasDriverFactoryExtensions.Register(registry); ModbusDriverFactoryExtensions.Register(registry); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index 4555272..70a6035 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -36,7 +36,6 @@ - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/HierarchyParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/HierarchyParityTests.cs deleted file mode 100644 index d9c35cf..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/HierarchyParityTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E; - -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class HierarchyParityTests -{ - private readonly ParityFixture _fx; - public HierarchyParityTests(ParityFixture fx) => _fx = fx; - - [Fact] - public async Task Discover_returns_at_least_one_gobject_with_attributes() - { - _fx.SkipIfUnavailable(); - - var builder = new RecordingAddressSpaceBuilder(); - await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None); - - builder.Folders.Count.ShouldBeGreaterThan(0, - "live Galaxy ZB has at least one deployed gobject"); - builder.Variables.Count.ShouldBeGreaterThan(0, - "at least one gobject in the dev Galaxy carries dynamic attributes"); - } - - [Fact] - public async Task Discover_emits_only_lowercase_browse_paths_for_each_attribute() - { - // OPC UA browse paths are case-sensitive; the v1 server emits Galaxy attribute - // names verbatim (camelCase like "PV.Input.Value"). Parity invariant: every - // emitted variable's full reference contains a '.' separating the gobject - // tag-name from the attribute name (Galaxy reference grammar). - _fx.SkipIfUnavailable(); - - var builder = new RecordingAddressSpaceBuilder(); - await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None); - - builder.Variables.ShouldAllBe(v => v.AttributeInfo.FullName.Contains('.'), - "Galaxy MXAccess full references are 'tag.attribute'"); - } - - [Fact] - public async Task Discover_marks_at_least_one_attribute_as_historized_when_HistoryExtension_present() - { - _fx.SkipIfUnavailable(); - - var builder = new RecordingAddressSpaceBuilder(); - await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None); - - // Soft assertion — some Galaxies are configuration-only with no Historian extensions. - // We only check the field flows through correctly when populated. - var historized = builder.Variables.Count(v => v.AttributeInfo.IsHistorized); - // Just assert the count is non-negative — the value itself is data-dependent. - historized.ShouldBeGreaterThanOrEqualTo(0); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs deleted file mode 100644 index 4c40c3b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Diagnostics; -using System.Net.Sockets; -using System.Reflection; -using System.Security.Principal; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E; - -/// -/// Spawns one OtOpcUa.Driver.Galaxy.Host.exe subprocess per test class and exposes -/// a connected for the tests. Per Phase 2 plan §"Stream E -/// Parity Validation": the Proxy owns a session against a real out-of-process Host running -/// the production-shape MxAccessGalaxyBackend backed by live ZB + MXAccess COM. -/// Skipped when the Host EXE isn't built or when ZB SQL is unreachable. -/// -public sealed class ParityFixture : IAsyncLifetime -{ - public GalaxyProxyDriver? Driver { get; private set; } - public string? SkipReason { get; private set; } - - private Process? _host; - private const string Secret = "parity-suite-secret"; - - public async ValueTask InitializeAsync() - { - if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; } - if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; } - - var hostExe = FindHostExe(); - if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; } - - // Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL - // path without requiring a healthy MXAccess connection. Tests that need MXAccess - // override via env vars before InitializeAsync is called (use a separate fixture). - var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}"; - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!; - - var psi = new ProcessStartInfo(hostExe) - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - EnvironmentVariables = - { - ["OTOPCUA_GALAXY_PIPE"] = pipe, - ["OTOPCUA_ALLOWED_SID"] = sid.Value, - ["OTOPCUA_GALAXY_SECRET"] = Secret, - ["OTOPCUA_GALAXY_BACKEND"] = "db", - ["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", - }, - }; - - _host = Process.Start(psi) - ?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE"); - - // Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this - // in production with retry, but the parity tests are best served by a fixed warm-up. - await Task.Delay(2_000); - - Driver = new GalaxyProxyDriver(new GalaxyProxyOptions - { - DriverInstanceId = "parity", - PipeName = pipe, - SharedSecret = Secret, - ConnectTimeout = TimeSpan.FromSeconds(5), - }); - - await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); - } - - public async ValueTask DisposeAsync() - { - if (Driver is not null) - { - try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ } - Driver.Dispose(); - } - - if (_host is not null && !_host.HasExited) - { - try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ } - try { _host.WaitForExit(5_000); } catch { /* ignore */ } - } - _host?.Dispose(); - } - - /// Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern. - public void SkipIfUnavailable() - { - if (SkipReason is not null) - Assert.Skip(SkipReason); - } - - private static async Task ZbReachableAsync() - { - try - { - using var client = new TcpClient(); - var task = client.ConnectAsync("localhost", 1433); - return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; - } - catch { return false; } - } - - private static string? FindHostExe() - { - var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - var solutionRoot = asmDir; - for (var i = 0; i < 8 && solutionRoot is not null; i++) - { - if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break; - solutionRoot = Path.GetDirectoryName(solutionRoot); - } - if (solutionRoot is null) return null; - - var path = Path.Combine(solutionRoot, - "src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48", - "OtOpcUa.Driver.Galaxy.Host.exe"); - return File.Exists(path) ? path : null; - } -} - -[CollectionDefinition(nameof(ParityCollection))] -public sealed class ParityCollection : ICollectionFixture { } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/RecordingAddressSpaceBuilder.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/RecordingAddressSpaceBuilder.cs deleted file mode 100644 index 461e421..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/RecordingAddressSpaceBuilder.cs +++ /dev/null @@ -1,70 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E; - -/// -/// Test-only that records every Folder + Variable -/// registration. Mirrors the v1 in-process address-space build so tests can assert on -/// the same shape the legacy LmxNodeManager produced. -/// -public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder -{ - public List Folders { get; } = new(); - public List Variables { get; } = new(); - public List Properties { get; } = new(); - public List AlarmConditions { get; } = new(); - - public IAddressSpaceBuilder Folder(string browseName, string displayName) - { - Folders.Add(new RecordedFolder(browseName, displayName)); - return this; // single flat builder for tests; nesting irrelevant for parity assertions - } - - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo)); - return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions); - } - - public void AddProperty(string browseName, DriverDataType dataType, object? value) - { - Properties.Add(new RecordedProperty(browseName, dataType, value)); - } - - public sealed record RecordedFolder(string BrowseName, string DisplayName); - public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo); - public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value); - public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info); - public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args); - - /// - /// Sink the tests assert on to verify the alarm event forwarder routed a transition - /// to the correct source-node-id. One entry per . - /// - public List AlarmTransitions { get; } = new(); - - private sealed class RecordedVariableHandle : IVariableHandle - { - private readonly List _conditions; - public string FullReference { get; } - public RecordedVariableHandle(string fullReference, List conditions) - { - FullReference = fullReference; - _conditions = conditions; - } - - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) - { - _conditions.Add(new RecordedAlarmCondition(FullReference, info)); - return new RecordingSink(FullReference); - } - - private sealed class RecordingSink : IAlarmConditionSink - { - public string SourceNodeId { get; } - public List Received { get; } = new(); - public RecordingSink(string sourceNodeId) => SourceNodeId = sourceNodeId; - public void OnTransition(AlarmEventArgs args) => Received.Add(args); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs deleted file mode 100644 index be34ef4..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E; - -/// -/// Regression tests for the four 2026-04-13 stability findings (commits c76ab8f, -/// 7310925) per Phase 2 plan §"Stream E.3". Each test asserts the v2 topology -/// does not reintroduce the v1 defect. -/// -[Trait("Category", "ParityE2E")] -[Trait("Subcategory", "StabilityRegression")] -[Collection(nameof(ParityCollection))] -public sealed class StabilityFindingsRegressionTests -{ - private readonly ParityFixture _fx; - public StabilityFindingsRegressionTests(ParityFixture fx) => _fx = fx; - - /// - /// Finding #1 — phantom probe subscription flips Tick() to Stopped. When the - /// v1 GalaxyRuntimeProbeManager failed to subscribe a probe, it left a phantom entry - /// that the next Tick() flipped to Stopped, fanning Bad-quality across unrelated - /// subtrees. v2 regression net: a failed subscribe must not affect host status of - /// subscriptions that did succeed. - /// - [Fact] - public async Task Failed_subscribe_does_not_corrupt_unrelated_host_status() - { - _fx.SkipIfUnavailable(); - - // GetHostStatuses pre-subscribe — baseline. - var preSubscribe = _fx.Driver!.GetHostStatuses().Count; - - // Try to subscribe to a nonsense reference; the Host should reject it without - // poisoning the host-status table. - try - { - await _fx.Driver.SubscribeAsync( - new[] { "nonexistent.tag.does.not.exist[]" }, - TimeSpan.FromSeconds(1), - CancellationToken.None); - } - catch { /* expected — bad reference */ } - - var postSubscribe = _fx.Driver.GetHostStatuses().Count; - postSubscribe.ShouldBe(preSubscribe, - "failed subscribe must not mutate the host-status snapshot"); - } - - /// - /// Finding #2 — cross-host quality clear wipes sibling state during recovery. - /// v1 cleared all subscriptions when ANY host changed state, even healthy peers. - /// v2 regression net: host-status events must be scoped to the affected host name. - /// - [Fact] - public void Host_status_change_event_carries_specific_host_name_not_global_clear() - { - _fx.SkipIfUnavailable(); - - var changes = new List(); - EventHandler handler = (_, e) => changes.Add(e); - _fx.Driver!.OnHostStatusChanged += handler; - try - { - // We can't deterministically force a Host status transition in the suite without - // tearing down the COM connection. The structural assertion is sufficient: the - // event TYPE carries a specific HostName, OldState, NewState — there is no - // "global clear" payload. v1's bug was structural; v2's event signature - // mathematically prevents reintroduction. - typeof(HostStatusChangedEventArgs).GetProperty("HostName") - .ShouldNotBeNull("event signature must scope to a specific host"); - typeof(HostStatusChangedEventArgs).GetProperty("OldState") - .ShouldNotBeNull(); - typeof(HostStatusChangedEventArgs).GetProperty("NewState") - .ShouldNotBeNull(); - } - finally - { - _fx.Driver.OnHostStatusChanged -= handler; - } - } - - /// - /// Finding #3 — sync-over-async on the OPC UA stack thread. v1 had spots - /// that called .Result / .Wait() from the OPC UA stack callback, - /// deadlocking under load. v2 regression net: every - /// capability method is async-all-the-way; a reflection scan asserts no - /// .GetAwaiter().GetResult() appears in IL of the public surface. - /// Implemented as a structural shape assertion — every public method returning - /// or . - /// - [Fact] - public void All_GalaxyProxyDriver_capability_methods_return_Task_for_async_correctness() - { - _fx.SkipIfUnavailable(); - - var driverType = typeof(Proxy.GalaxyProxyDriver); - var capabilityMethods = driverType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Where(m => m.DeclaringType == driverType - && !m.IsSpecialName - && m.Name is "InitializeAsync" or "ReinitializeAsync" or "ShutdownAsync" - or "FlushOptionalCachesAsync" or "DiscoverAsync" - or "ReadAsync" or "WriteAsync" - or "SubscribeAsync" or "UnsubscribeAsync" - or "SubscribeAlarmsAsync" or "UnsubscribeAlarmsAsync" or "AcknowledgeAsync" - or "ReadRawAsync" or "ReadProcessedAsync"); - - foreach (var m in capabilityMethods) - { - (m.ReturnType == typeof(Task) || m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) - .ShouldBeTrue($"{m.Name} must return Task or Task — sync-over-async risks deadlock under load"); - } - } - - /// - /// Finding #4 — fire-and-forget alarm tasks racing shutdown. v1 fired - /// Task.Run(() => raiseAlarm) without awaiting, so shutdown could complete - /// while the task was still touching disposed state. v2 regression net: alarm - /// acknowledgement is sequential and awaited — verified by the integration test - /// AcknowledgeAsync returning a completed Task that doesn't leave background - /// work. - /// - [Fact] - public async Task AcknowledgeAsync_completes_before_returning_no_background_tasks() - { - _fx.SkipIfUnavailable(); - - // We can't easily acknowledge a real Galaxy alarm in this fixture, but we can - // assert the call shape: a synchronous-from-the-caller-perspective await without - // throwing or leaving a pending continuation. - await _fx.Driver!.AcknowledgeAsync( - new[] { new AlarmAcknowledgeRequest("nonexistent-source", "nonexistent-event", "test ack") }, - CancellationToken.None); - - // If we got here, the call awaited cleanly — no fire-and-forget background work - // left running after the caller returned. - true.ShouldBeTrue(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj deleted file mode 100644 index eaabdc7..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net10.0 - enable - enable - false - true - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs deleted file mode 100644 index 27541ab..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AlarmDiscoveryTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class AlarmDiscoveryTests -{ - /// - /// PR 9 — IsAlarm must survive the MessagePack round-trip at Key=6 position. - /// Regression guard: any reorder of keys in GalaxyAttributeInfo would silently corrupt - /// the flag in the wire payload since MessagePack encodes by key number, not field name. - /// - [Fact] - public void GalaxyAttributeInfo_IsAlarm_round_trips_true_through_MessagePack() - { - var input = new GalaxyAttributeInfo - { - AttributeName = "TankLevel", - MxDataType = 2, - IsArray = false, - ArrayDim = null, - SecurityClassification = 1, - IsHistorized = true, - IsAlarm = true, - }; - - var bytes = MessagePackSerializer.Serialize(input); - var decoded = MessagePackSerializer.Deserialize(bytes); - - decoded.IsAlarm.ShouldBeTrue(); - decoded.IsHistorized.ShouldBeTrue(); - decoded.AttributeName.ShouldBe("TankLevel"); - } - - [Fact] - public void GalaxyAttributeInfo_IsAlarm_round_trips_false_through_MessagePack() - { - var input = new GalaxyAttributeInfo { AttributeName = "ColorRgb", IsAlarm = false }; - var bytes = MessagePackSerializer.Serialize(input); - var decoded = MessagePackSerializer.Deserialize(bytes); - decoded.IsAlarm.ShouldBeFalse(); - } - - /// - /// Wire-compat guard: payloads serialized before PR 9 (which omit Key=6) must still - /// deserialize cleanly — MessagePack treats missing keys as default. This lets a newer - /// Proxy talk to an older Host during a rolling upgrade without a crash. - /// - [Fact] - public void Pre_PR9_payload_without_IsAlarm_key_deserializes_with_default_false() - { - // Build a 6-field payload (keys 0..5) matching the pre-PR9 shape by serializing a - // stand-in class with the same key layout but no Key=6. - var pre = new PrePR9Shape - { - AttributeName = "Legacy", - MxDataType = 1, - IsArray = false, - ArrayDim = null, - SecurityClassification = 0, - IsHistorized = false, - }; - var bytes = MessagePackSerializer.Serialize(pre); - - var decoded = MessagePackSerializer.Deserialize(bytes); - decoded.AttributeName.ShouldBe("Legacy"); - decoded.IsAlarm.ShouldBeFalse(); - } - - [MessagePackObject] - public sealed class PrePR9Shape - { - [Key(0)] public string AttributeName { get; set; } = string.Empty; - [Key(1)] public int MxDataType { get; set; } - [Key(2)] public bool IsArray { get; set; } - [Key(3)] public uint? ArrayDim { get; set; } - [Key(4)] public int SecurityClassification { get; set; } - [Key(5)] public bool IsHistorized { get; set; } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs deleted file mode 100644 index 7cdcd87..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/AvevaPrerequisitesLiveTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using Xunit.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// Exercises against the live dev box so the helper - /// itself gets integration coverage — i.e. "do the probes return Pass for things that - /// really are Pass?" as validated against this machine's known-installed topology. - /// Category LiveGalaxy so CI / clean dev boxes skip cleanly. - /// - [Trait("Category", "LiveGalaxy")] - public sealed class AvevaPrerequisitesLiveTests - { - private readonly ITestOutputHelper _output; - - public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output; - - [Fact] - public async Task CheckAll_on_live_box_reports_Framework_install() - { - var report = await AvevaPrerequisites.CheckAllAsync(); - _output.WriteLine(report.ToString()); - report.Checks.ShouldContain(c => - c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass, - "ArchestrA Framework registry root should be found on this machine."); - } - - [Fact] - public async Task CheckAll_on_live_box_reports_aaBootstrap_running() - { - var report = await AvevaPrerequisites.CheckAllAsync(); - var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap"); - bootstrap.ShouldNotBeNull(); - bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass, - $"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}"); - } - - [Fact] - public async Task CheckAll_on_live_box_reports_aaGR_running() - { - var report = await AvevaPrerequisites.CheckAllAsync(); - var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR"); - gr.ShouldNotBeNull(); - gr.Status.ShouldBe(PrerequisiteStatus.Pass, - $"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}"); - } - - [Fact] - public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered() - { - var report = await AvevaPrerequisites.CheckAllAsync(); - var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy"); - com.ShouldNotBeNull(); - com.Status.ShouldBe(PrerequisiteStatus.Pass, - $"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}"); - } - - [Fact] - public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable() - { - var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None); - var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB"); - zb.ShouldNotBeNull(); - zb.Status.ShouldBe(PrerequisiteStatus.Pass, - $"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}"); - } - - [Fact] - public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects() - { - // This box has 49 deployed objects per the research; we just assert > 0 so adding/ - // removing objects doesn't break the test. - var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(); - var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects"); - deployed.ShouldNotBeNull(); - deployed.Status.ShouldBe(PrerequisiteStatus.Pass, - $"At least one deployed gobject should exist — detail: {deployed.Detail}"); - } - - [Fact] - public async Task Aveva_side_is_ready_on_this_machine() - { - // Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost) - // may not be installed on a developer's box while they're actively iterating on - // them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM / - // SQL / core services) should always be up on a machine with System Platform - // installed. This assertion is what gates live-Galaxy tests that go straight to - // the Galaxy Repository without routing through our stack. - var report = await AvevaPrerequisites.CheckAllAsync( - new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false }); - _output.WriteLine(report.ToString()); - _output.WriteLine(report.Warnings ?? "no warnings"); - - // Enumerate AVEVA-side failures (if any) for an actionable assertion message. - var avevaFails = report.Checks - .Where(c => c.Status == PrerequisiteStatus.Fail && - c.Category != PrerequisiteCategory.OtOpcUaService) - .ToList(); - report.IsAvevaSideReady.ShouldBeTrue( - avevaFails.Count == 0 - ? "unexpected state" - : "AVEVA-side failures: " + string.Join(" ; ", - avevaFails.Select(f => $"{f.Name}: {f.Detail}"))); - } - - [Fact] - public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed() - { - // The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if - // they're not installed yet — absence is itself an actionable signal. This test - // doesn't assert Pass/Fail on those services (their state depends on what's - // installed when the test runs) — it only asserts the helper EMITTED the rows, - // so nobody can ship a prerequisite check that silently omits our own services. - var report = await AvevaPrerequisites.CheckAllAsync(); - - report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost"); - report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa"); - report.Checks.ShouldContain(c => c.Name == "service:GLAuth"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/EndToEndIpcTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/EndToEndIpcTests.cs deleted file mode 100644 index 760ed89..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/EndToEndIpcTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.IO; -using System.IO.Pipes; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// Drives every the Phase 2 plan exposes through the full - /// Host-side stack ( + + - /// ) using a hand-rolled IPC client built on Shared's - /// /. The Proxy's GalaxyIpcClient - /// is net10-only and cannot load in this net48 x86 test process, so we exercise the same - /// wire protocol through the framing primitives directly. The dispatcher/backend response - /// shapes are the production code path verbatim. - /// - [Trait("Category", "Integration")] - public sealed class EndToEndIpcTests - { - private sealed class TestStack : IDisposable - { - public PipeServer Server = null!; - public NamedPipeClientStream Stream = null!; - public FrameReader Reader = null!; - public FrameWriter Writer = null!; - public Task ServerTask = null!; - public CancellationTokenSource Cts = null!; - - public void Dispose() - { - Cts.Cancel(); - try { ServerTask.GetAwaiter().GetResult(); } catch { /* shutdown */ } - Server.Dispose(); - Stream.Dispose(); - Reader.Dispose(); - Writer.Dispose(); - Cts.Dispose(); - } - } - - private static async Task StartAsync() - { - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!; - var pipe = $"OtOpcUaGalaxyE2E-{Guid.NewGuid():N}"; - const string secret = "e2e-secret"; - Logger log = new LoggerConfiguration().CreateLogger(); - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - - var server = new PipeServer(pipe, sid, secret, log); - var serverTask = Task.Run(() => server.RunAsync( - new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token)); - - var stream = new NamedPipeClientStream(".", pipe, PipeDirection.InOut, PipeOptions.Asynchronous); - await stream.ConnectAsync(5_000, cts.Token); - var reader = new FrameReader(stream, leaveOpen: true); - var writer = new FrameWriter(stream, leaveOpen: true); - await writer.WriteAsync(MessageKind.Hello, - new Hello { PeerName = "e2e", SharedSecret = secret }, cts.Token); - var ack = await reader.ReadFrameAsync(cts.Token); - if (ack is null || ack.Value.Kind != MessageKind.HelloAck) - throw new InvalidOperationException("Hello handshake failed"); - - return new TestStack - { - Server = server, - Stream = stream, - Reader = reader, - Writer = writer, - ServerTask = serverTask, - Cts = cts, - }; - } - - private static async Task RoundTripAsync( - TestStack s, MessageKind reqKind, TReq req, MessageKind respKind) - { - await s.Writer.WriteAsync(reqKind, req, s.Cts.Token); - var frame = await s.Reader.ReadFrameAsync(s.Cts.Token); - frame.HasValue.ShouldBeTrue(); - frame!.Value.Kind.ShouldBe(respKind); - return MessagePackSerializer.Deserialize(frame.Value.Body); - } - - [Fact] - public async Task OpenSession_succeeds_with_an_assigned_session_id() - { - using var s = await StartAsync(); - - var resp = await RoundTripAsync( - s, MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "gal-e2e", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse); - - resp.Success.ShouldBeTrue(); - resp.SessionId.ShouldBeGreaterThan(0L); - } - - [Fact] - public async Task Discover_against_stub_returns_an_error_response() - { - using var s = await StartAsync(); - - var resp = await RoundTripAsync( - s, MessageKind.DiscoverHierarchyRequest, - new DiscoverHierarchyRequest { SessionId = 1 }, - MessageKind.DiscoverHierarchyResponse); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("MXAccess code lift pending"); - } - - [Fact] - public async Task WriteValues_returns_per_tag_BadInternalError_status() - { - using var s = await StartAsync(); - - var resp = await RoundTripAsync( - s, MessageKind.WriteValuesRequest, - new WriteValuesRequest - { - SessionId = 1, - Writes = new[] { new GalaxyDataValue { TagReference = "TagA" } }, - }, - MessageKind.WriteValuesResponse); - - resp.Results.Length.ShouldBe(1); - resp.Results[0].StatusCode.ShouldBe(0x80020000u); - } - - [Fact] - public async Task Subscribe_returns_a_subscription_id() - { - using var s = await StartAsync(); - - var sub = await RoundTripAsync( - s, MessageKind.SubscribeRequest, - new SubscribeRequest { SessionId = 1, TagReferences = new[] { "TagA" }, RequestedIntervalMs = 500 }, - MessageKind.SubscribeResponse); - - sub.Success.ShouldBeTrue(); - sub.SubscriptionId.ShouldBeGreaterThan(0L); - } - - [Fact] - public async Task Recycle_returns_the_grace_window_from_the_backend() - { - using var s = await StartAsync(); - - var resp = await RoundTripAsync( - s, MessageKind.RecycleHostRequest, - new RecycleHostRequest { Kind = "Soft", Reason = "test" }, - MessageKind.RecycleStatusResponse); - - resp.Accepted.ShouldBeTrue(); - resp.GraceSeconds.ShouldBe(15); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs deleted file mode 100644 index 203f3de..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class GalaxyAlarmTrackerTests -{ - private sealed class FakeSubscriber - { - public readonly ConcurrentDictionary> Subs = new(); - public readonly ConcurrentQueue Unsubs = new(); - public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new(); - public bool WriteReturns { get; set; } = true; - - public Task Subscribe(string tag, Action cb) - { - Subs[tag] = cb; - return Task.CompletedTask; - } - public Task Unsubscribe(string tag) - { - Unsubs.Enqueue(tag); - Subs.TryRemove(tag, out _); - return Task.CompletedTask; - } - public Task Write(string tag, object value) - { - Writes.Enqueue((tag, value)); - return Task.FromResult(WriteReturns); - } - } - - private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192); - private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192); - private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192); - - [Fact] - public async Task Track_subscribes_to_four_alarm_attributes() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - - await t.TrackAsync("Tank.Level.HiHi"); - - fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm"); - fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority"); - fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName"); - fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked"); - t.TrackedAlarmCount.ShouldBe(1); - } - - [Fact] - public async Task Track_is_idempotent_on_repeat_call() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - - await t.TrackAsync("Alarm.A"); - await t.TrackAsync("Alarm.A"); - - t.TrackedAlarmCount.ShouldBe(1); - fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8 - } - - [Fact] - public async Task InAlarm_false_to_true_fires_Active_transition() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - var transitions = new ConcurrentQueue(); - t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); - - await t.TrackAsync("Alarm.A"); - fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500)); - fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi")); - fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); - - transitions.Count.ShouldBe(1); - transitions.TryDequeue(out var tr).ShouldBeTrue(); - tr!.Transition.ShouldBe(AlarmStateTransition.Active); - tr.Priority.ShouldBe(500); - tr.DescAttrName.ShouldBe("TankLevelHiHi"); - } - - [Fact] - public async Task InAlarm_true_to_false_fires_Inactive_transition() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - var transitions = new ConcurrentQueue(); - t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); - - await t.TrackAsync("Alarm.A"); - fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); - fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false)); - - transitions.Count.ShouldBe(2); - transitions.TryDequeue(out _); - transitions.TryDequeue(out var tr).ShouldBeTrue(); - tr!.Transition.ShouldBe(AlarmStateTransition.Inactive); - } - - [Fact] - public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - var transitions = new ConcurrentQueue(); - t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); - - await t.TrackAsync("Alarm.A"); - fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag - fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged - - transitions.Count.ShouldBe(2); - transitions.TryDequeue(out _); - transitions.TryDequeue(out var tr).ShouldBeTrue(); - tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged); - } - - [Fact] - public async Task Acked_transition_while_InAlarm_is_false_does_not_fire() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - var transitions = new ConcurrentQueue(); - t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); - - await t.TrackAsync("Alarm.A"); - // Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire. - fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); - - transitions.Count.ShouldBe(0); - } - - [Fact] - public async Task Acknowledge_writes_AckMsg_with_comment() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - await t.TrackAsync("Alarm.A"); - - var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator"); - - ok.ShouldBeTrue(); - fake.Writes.Count.ShouldBe(1); - fake.Writes.TryDequeue(out var w).ShouldBeTrue(); - w.Tag.ShouldBe("Alarm.A.AckMsg"); - w.Value.ShouldBe("acknowledged by operator"); - } - - [Fact] - public async Task Snapshot_reports_latest_fields() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - await t.TrackAsync("Alarm.A"); - fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); - fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900)); - fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm")); - fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); - - var snap = t.SnapshotStates(); - snap.Count.ShouldBe(1); - snap[0].InAlarm.ShouldBeTrue(); - snap[0].Acked.ShouldBeTrue(); - snap[0].Priority.ShouldBe(900); - snap[0].DescAttrName.ShouldBe("MyAlarm"); - } - - [Fact] - public async Task Foreign_probe_callback_is_dropped() - { - var fake = new FakeSubscriber(); - using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); - var transitions = new ConcurrentQueue(); - t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); - - // No TrackAsync was called — this callback is foreign and should be silently ignored. - t.OnProbeCallback("Unknown.InAlarm", Bool(true)); - - transitions.Count.ShouldBe(0); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs deleted file mode 100644 index c3996ad..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// Live smoke against the Galaxy ZB repository. Skipped when ZB is unreachable so - /// CI / dev boxes without an AVEVA install still pass. Exercises the ported - /// + against the same - /// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the - /// DiscoverHierarchyResponse shape. - /// - /// - /// Since PR 36, skip logic is delegated to - /// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server - /// unreachable") instead of a silent return. - /// - [Trait("Category", "LiveGalaxy")] - public sealed class GalaxyRepositoryLiveSmokeTests - { - private static GalaxyRepositoryOptions DevZbOptions() => new() - { - ConnectionString = - "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;", - CommandTimeoutSeconds = 10, - }; - - private static async Task RepositorySkipReasonAsync() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); - var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync( - DevZbOptions().ConnectionString, cts.Token); - return report.SkipReason; - } - - private static async Task ZbReachableAsync() - { - // Legacy silent-skip adapter — keeps the existing tests compiling while - // gradually migrating to the Skip-with-reason pattern. Returns true when the - // prerequisite check has no Fail entries. - return (await RepositorySkipReasonAsync()) is null; - } - - [Fact] - public async Task TestConnection_returns_true_against_live_ZB() - { - if (!await ZbReachableAsync()) return; - - var repo = new GalaxyRepository(DevZbOptions()); - (await repo.TestConnectionAsync()).ShouldBeTrue(); - } - - [Fact] - public async Task GetHierarchy_returns_at_least_one_deployed_gobject() - { - if (!await ZbReachableAsync()) return; - - var repo = new GalaxyRepository(DevZbOptions()); - var rows = await repo.GetHierarchyAsync(); - - rows.Count.ShouldBeGreaterThan(0, - "the dev Galaxy has at least the WinPlatform + AppEngine deployed"); - rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName)); - } - - [Fact] - public async Task GetAttributes_returns_attributes_for_deployed_objects() - { - if (!await ZbReachableAsync()) return; - - var repo = new GalaxyRepository(DevZbOptions()); - var attrs = await repo.GetAttributesAsync(); - - attrs.Count.ShouldBeGreaterThan(0); - attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains(".")); - } - - [Fact] - public async Task GetLastDeployTime_returns_a_value() - { - if (!await ZbReachableAsync()) return; - - var repo = new GalaxyRepository(DevZbOptions()); - var ts = await repo.GetLastDeployTimeAsync(); - ts.ShouldNotBeNull(); - } - - [Fact] - public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories() - { - if (!await ZbReachableAsync()) return; - - var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions())); - var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None); - - resp.Success.ShouldBeTrue(resp.Error); - resp.Objects.Length.ShouldBeGreaterThan(0); - - var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0); - firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes"); - firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty(); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRuntimeProbeManagerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRuntimeProbeManagerTests.cs deleted file mode 100644 index 7a11318..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRuntimeProbeManagerTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class GalaxyRuntimeProbeManagerTests -{ - private sealed class FakeSubscriber - { - public readonly ConcurrentDictionary> Subs = new(); - public readonly ConcurrentQueue UnsubCalls = new(); - public bool FailSubscribeFor { get; set; } - public string? FailSubscribeTag { get; set; } - - public Task Subscribe(string probe, Action cb) - { - if (FailSubscribeFor && string.Equals(probe, FailSubscribeTag, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("subscribe refused"); - Subs[probe] = cb; - return Task.CompletedTask; - } - - public Task Unsubscribe(string probe) - { - UnsubCalls.Enqueue(probe); - Subs.TryRemove(probe, out _); - return Task.CompletedTask; - } - } - - private static Vtq Good(bool scanState) => new(scanState, DateTime.UtcNow, 192); - private static Vtq Bad() => new(null, DateTime.UtcNow, 0); - - [Fact] - public async Task Sync_subscribes_to_ScanState_per_host() - { - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe); - - await mgr.SyncAsync(new[] - { - new HostProbeTarget("PlatformA", GalaxyRuntimeProbeManager.CategoryWinPlatform), - new HostProbeTarget("EngineB", GalaxyRuntimeProbeManager.CategoryAppEngine), - }); - - mgr.ActiveProbeCount.ShouldBe(2); - subs.Subs.ShouldContainKey("PlatformA.ScanState"); - subs.Subs.ShouldContainKey("EngineB.ScanState"); - } - - [Fact] - public async Task Sync_is_idempotent_on_repeat_call_with_same_set() - { - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe); - var targets = new[] { new HostProbeTarget("PlatformA", 1) }; - - await mgr.SyncAsync(targets); - await mgr.SyncAsync(targets); - - mgr.ActiveProbeCount.ShouldBe(1); - subs.Subs.Count.ShouldBe(1); - subs.UnsubCalls.Count.ShouldBe(0); - } - - [Fact] - public async Task Sync_unadvises_removed_hosts() - { - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe); - - await mgr.SyncAsync(new[] - { - new HostProbeTarget("PlatformA", 1), - new HostProbeTarget("PlatformB", 1), - }); - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - - mgr.ActiveProbeCount.ShouldBe(1); - subs.UnsubCalls.ShouldContain("PlatformB.ScanState"); - } - - [Fact] - public async Task Subscribe_failure_rolls_back_host_entry_so_later_transitions_do_not_fire_stale_events() - { - var subs = new FakeSubscriber { FailSubscribeFor = true, FailSubscribeTag = "PlatformA.ScanState" }; - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - - mgr.ActiveProbeCount.ShouldBe(0); // rolled back - mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Unknown); - } - - [Fact] - public async Task Unknown_to_Running_does_not_fire_StateChanged() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - var transitions = new ConcurrentQueue(); - mgr.StateChanged += (_, t) => transitions.Enqueue(t); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); - - mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Running); - transitions.Count.ShouldBe(0); // startup transition, operators don't care - } - - [Fact] - public async Task Running_to_Stopped_fires_StateChanged_with_both_states() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - var transitions = new ConcurrentQueue(); - mgr.StateChanged += (_, t) => transitions.Enqueue(t); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent) - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires) - - transitions.Count.ShouldBe(1); - transitions.TryDequeue(out var t).ShouldBeTrue(); - t!.TagName.ShouldBe("PlatformA"); - t.OldState.ShouldBe(HostRuntimeState.Running); - t.NewState.ShouldBe(HostRuntimeState.Stopped); - } - - [Fact] - public async Task Stopped_to_Running_fires_StateChanged_for_recovery() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - var transitions = new ConcurrentQueue(); - mgr.StateChanged += (_, t) => transitions.Enqueue(t); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent) - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires) - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Stopped→Running (fires) - - transitions.Count.ShouldBe(2); - } - - [Fact] - public async Task Unknown_to_Stopped_fires_StateChanged_for_first_known_bad_signal() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - var transitions = new ConcurrentQueue(); - mgr.StateChanged += (_, t) => transitions.Enqueue(t); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - // First callback is bad-quality — we must flag the host Stopped so operators see it. - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Bad()); - - transitions.Count.ShouldBe(1); - transitions.TryDequeue(out var t).ShouldBeTrue(); - t!.OldState.ShouldBe(HostRuntimeState.Unknown); - t.NewState.ShouldBe(HostRuntimeState.Stopped); - } - - [Fact] - public async Task Repeated_Good_Running_callbacks_do_not_fire_duplicate_events() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - var count = 0; - mgr.StateChanged += (_, _) => Interlocked.Increment(ref count); - - await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) }); - for (var i = 0; i < 5; i++) - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); - - count.ShouldBe(0); // only the silent Unknown→Running on the first, no events after - } - - [Fact] - public async Task Unknown_callback_for_non_tracked_probe_is_dropped() - { - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe); - - mgr.OnProbeCallback("ProbeForSomeoneElse.ScanState", Good(true)); - - mgr.ActiveProbeCount.ShouldBe(0); - } - - [Fact] - public async Task Snapshot_reports_current_state_for_every_tracked_host() - { - var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var subs = new FakeSubscriber(); - using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now); - - await mgr.SyncAsync(new[] - { - new HostProbeTarget("PlatformA", 1), - new HostProbeTarget("EngineB", 3), - }); - subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Running - subs.Subs["EngineB.ScanState"]("EngineB.ScanState", Bad()); // Stopped - - var snap = mgr.SnapshotStates(); - snap.Count.ShouldBe(2); - snap.ShouldContain(s => s.TagName == "PlatformA" && s.State == HostRuntimeState.Running); - snap.ShouldContain(s => s.TagName == "EngineB" && s.State == HostRuntimeState.Stopped); - } - - [Fact] - public void IsRuntimeHost_recognizes_WinPlatform_and_AppEngine_category_ids() - { - new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryWinPlatform).IsRuntimeHost.ShouldBeTrue(); - new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryAppEngine).IsRuntimeHost.ShouldBeTrue(); - new HostProbeTarget("X", 4 /* $Area */).IsRuntimeHost.ShouldBeFalse(); - new HostProbeTarget("X", 11 /* $ApplicationObject */).IsRuntimeHost.ShouldBeFalse(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianWiringTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianWiringTests.cs deleted file mode 100644 index 67f3885..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianWiringTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - [Trait("Category", "Unit")] - public sealed class HistorianWiringTests - { - /// - /// When the Proxy sends a HistoryRead but the supervisor never enabled the historian - /// (OTOPCUA_HISTORIAN_ENABLED unset), we expect a clean Success=false with a - /// self-explanatory error — not an exception or a hang against localhost. - /// - [Fact] - public async Task HistoryReadAsync_returns_disabled_error_when_no_historian_configured() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests"); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - historian: null); - - var resp = await backend.HistoryReadAsync(new HistoryReadRequest - { - TagReferences = new[] { "TestTag" }, - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - MaxValuesPerTag = 100, - }, CancellationToken.None); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("Historian disabled"); - resp.Tags.ShouldBeEmpty(); - } - - /// - /// When the historian is wired up, we expect the backend to call through and map - /// samples onto the IPC wire shape. Uses a fake - /// that returns a single known-good sample so we can assert the mapping stays sane. - /// - [Fact] - public async Task HistoryReadAsync_maps_sample_to_GalaxyDataValue() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests"); - var fake = new FakeHistorianDataSource(new HistorianSample - { - Value = 42.5, - Quality = 192, // Good - TimestampUtc = new DateTime(2026, 4, 18, 9, 0, 0, DateTimeKind.Utc), - }); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - fake); - - var resp = await backend.HistoryReadAsync(new HistoryReadRequest - { - TagReferences = new[] { "TankLevel" }, - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - MaxValuesPerTag = 100, - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Tags.Length.ShouldBe(1); - resp.Tags[0].TagReference.ShouldBe("TankLevel"); - resp.Tags[0].Values.Length.ShouldBe(1); - resp.Tags[0].Values[0].StatusCode.ShouldBe(0u); // Good - resp.Tags[0].Values[0].ValueBytes.ShouldNotBeNull(); - resp.Tags[0].Values[0].SourceTimestampUtcUnixMs.ShouldBe( - new DateTimeOffset(2026, 4, 18, 9, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds()); - } - - private sealed class FakeHistorianDataSource : IHistorianDataSource - { - private readonly HistorianSample _sample; - public FakeHistorianDataSource(HistorianSample sample) => _sample = sample; - - public Task> ReadRawAsync(string tagName, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List { _sample }); - - public Task> ReadAggregateAsync(string tagName, DateTime s, DateTime e, double ms, string col, CancellationToken ct) - => Task.FromResult(new List()); - - public Task> ReadAtTimeAsync(string tagName, DateTime[] ts, CancellationToken ct) - => Task.FromResult(new List()); - - public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - - public HistorianHealthSnapshot GetHealthSnapshot() => new(); - public void Dispose() { } - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs deleted file mode 100644 index 37c1389..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadAtTimeTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class HistoryReadAtTimeTests -{ - private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) => - new( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"), - historian); - - [Fact] - public async Task Returns_disabled_error_when_no_historian_configured() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - using var backend = BuildBackend(null, pump); - - var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest - { - TagReference = "T", - TimestampsUtcUnixMs = new[] { 1L, 2L }, - }, CancellationToken.None); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("Historian disabled"); - } - - [Fact] - public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var fake = new FakeHistorian(); - using var backend = BuildBackend(fake, pump); - - var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest - { - TagReference = "T", - TimestampsUtcUnixMs = Array.Empty(), - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Values.ShouldBeEmpty(); - fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list - } - - [Fact] - public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc); - var fake = new FakeHistorian( - new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 }, - new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 }); - using var backend = BuildBackend(fake, pump); - - var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest - { - TagReference = "TankLevel", - TimestampsUtcUnixMs = new[] - { - new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(), - new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(), - }, - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Values.Length.ShouldBe(2); - resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds()); - resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192) - MessagePackSerializer.Deserialize(resp.Values[0].ValueBytes!).ShouldBe(100.0); - - fake.Calls.ShouldBe(1); - fake.LastTimestamps.Length.ShouldBe(2); - fake.LastTimestamps[0].ShouldBe(t1); - fake.LastTimestamps[1].ShouldBe(t2); - } - - [Fact] - public async Task Missing_sample_maps_to_Bad_category() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - // Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync. - var fake = new FakeHistorian(new HistorianSample - { - Value = null, - Quality = 0, - TimestampUtc = DateTime.UtcNow, - }); - using var backend = BuildBackend(fake, pump); - - var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest - { - TagReference = "T", - TimestampsUtcUnixMs = new[] { 1L }, - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Values.Length.ShouldBe(1); - resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category - resp.Values[0].ValueBytes.ShouldBeNull(); - } - - private sealed class FakeHistorian : IHistorianDataSource - { - private readonly HistorianSample[] _samples; - public int Calls { get; private set; } - public DateTime[] LastTimestamps { get; private set; } = Array.Empty(); - - public FakeHistorian(params HistorianSample[] samples) => _samples = samples; - - public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) - { - Calls++; - LastTimestamps = ts; - return Task.FromResult(new List(_samples)); - } - - public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - public Task> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct) - => Task.FromResult(new List()); - public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - public HistorianHealthSnapshot GetHealthSnapshot() => new(); - public void Dispose() { } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs deleted file mode 100644 index ac24443..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadEventsTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class HistoryReadEventsTests -{ - private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? h, StaPump pump) => - new( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - new MxAccessClient(pump, new MxProxyAdapter(), "events-test"), - h); - - [Fact] - public async Task Returns_disabled_error_when_no_historian_configured() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - using var backend = BuildBackend(null, pump); - - var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest - { - SourceName = "TankA", - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - MaxEvents = 100, - }, CancellationToken.None); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("Historian disabled"); - } - - [Fact] - public async Task Maps_HistorianEventDto_to_GalaxyHistoricalEvent_wire_shape() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - - var eventId = Guid.NewGuid(); - var eventTime = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); - var receivedTime = eventTime.AddMilliseconds(150); - var fake = new FakeHistorian(new HistorianEventDto - { - Id = eventId, - Source = "TankA.Level.HiHi", - EventTime = eventTime, - ReceivedTime = receivedTime, - DisplayText = "HiHi alarm tripped", - Severity = 900, - }); - using var backend = BuildBackend(fake, pump); - - var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest - { - SourceName = "TankA.Level.HiHi", - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - MaxEvents = 50, - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Events.Length.ShouldBe(1); - var got = resp.Events[0]; - got.EventId.ShouldBe(eventId.ToString()); - got.SourceName.ShouldBe("TankA.Level.HiHi"); - got.DisplayText.ShouldBe("HiHi alarm tripped"); - got.Severity.ShouldBe(900); - got.EventTimeUtcUnixMs.ShouldBe(new DateTimeOffset(eventTime, TimeSpan.Zero).ToUnixTimeMilliseconds()); - got.ReceivedTimeUtcUnixMs.ShouldBe(new DateTimeOffset(receivedTime, TimeSpan.Zero).ToUnixTimeMilliseconds()); - - fake.LastSourceName.ShouldBe("TankA.Level.HiHi"); - fake.LastMaxEvents.ShouldBe(50); - } - - [Fact] - public async Task Null_source_name_is_passed_through_as_all_sources() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var fake = new FakeHistorian(); - using var backend = BuildBackend(fake, pump); - - await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest - { - SourceName = null, - StartUtcUnixMs = 0, - EndUtcUnixMs = 1, - MaxEvents = 10, - }, CancellationToken.None); - - fake.LastSourceName.ShouldBeNull(); - } - - private sealed class FakeHistorian : IHistorianDataSource - { - private readonly HistorianEventDto[] _events; - public string? LastSourceName { get; private set; } = ""; - public int LastMaxEvents { get; private set; } - - public FakeHistorian(params HistorianEventDto[] events) => _events = events; - - public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) - { - LastSourceName = src; - LastMaxEvents = max; - return Task.FromResult(new List(_events)); - } - - public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - public Task> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct) - => Task.FromResult(new List()); - public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) - => Task.FromResult(new List()); - public HistorianHealthSnapshot GetHealthSnapshot() => new(); - public void Dispose() { } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs deleted file mode 100644 index aa86c50..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistoryReadProcessedTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class HistoryReadProcessedTests -{ - [Fact] - public async Task ReturnsDisabledError_When_NoHistorianConfigured() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - historian: null); - - var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest - { - TagReference = "T", - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - IntervalMs = 1000, - AggregateColumn = "Average", - }, CancellationToken.None); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("Historian disabled"); - } - - [Fact] - public async Task Rejects_NonPositiveInterval() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); - var fake = new FakeHistorianDataSource(); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - fake); - - var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest - { - TagReference = "T", - IntervalMs = 0, - AggregateColumn = "Average", - }, CancellationToken.None); - - resp.Success.ShouldBeFalse(); - resp.Error.ShouldContain("IntervalMs"); - } - - [Fact] - public async Task Maps_AggregateSample_With_Value_To_Good() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); - var fake = new FakeHistorianDataSource(new HistorianAggregateSample - { - Value = 12.34, - TimestampUtc = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc), - }); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - fake); - - var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest - { - TagReference = "T", - StartUtcUnixMs = 0, - EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - IntervalMs = 60_000, - AggregateColumn = "Average", - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Values.Length.ShouldBe(1); - resp.Values[0].StatusCode.ShouldBe(0u); // Good - resp.Values[0].ValueBytes.ShouldNotBeNull(); - MessagePackSerializer.Deserialize(resp.Values[0].ValueBytes!).ShouldBe(12.34); - fake.LastAggregateColumn.ShouldBe("Average"); - fake.LastIntervalMs.ShouldBe(60_000d); - } - - [Fact] - public async Task Maps_Null_Bucket_To_BadNoData() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test"); - var fake = new FakeHistorianDataSource(new HistorianAggregateSample - { - Value = null, - TimestampUtc = DateTime.UtcNow, - }); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - fake); - - var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest - { - TagReference = "T", - IntervalMs = 1000, - AggregateColumn = "Minimum", - }, CancellationToken.None); - - resp.Success.ShouldBeTrue(); - resp.Values.Length.ShouldBe(1); - resp.Values[0].StatusCode.ShouldBe(0x800E0000u); // BadNoData - resp.Values[0].ValueBytes.ShouldBeNull(); - } - - private sealed class FakeHistorianDataSource : IHistorianDataSource - { - private readonly HistorianAggregateSample[] _samples; - public string? LastAggregateColumn { get; private set; } - public double LastIntervalMs { get; private set; } - - public FakeHistorianDataSource(params HistorianAggregateSample[] samples) => _samples = samples; - - public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - - public Task> ReadAggregateAsync( - string tag, DateTime s, DateTime e, double intervalMs, string col, CancellationToken ct) - { - LastAggregateColumn = col; - LastIntervalMs = intervalMs; - return Task.FromResult(new List(_samples)); - } - - public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) - => Task.FromResult(new List()); - - public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) - => Task.FromResult(new List()); - - public HistorianHealthSnapshot GetHealthSnapshot() => new(); - public void Dispose() { } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs deleted file mode 100644 index f32f627..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class HostStatusPushTests -{ - /// - /// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false, - /// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with - /// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp. - /// This is the gateway-level signal; per-platform ScanState probes are deferred. - /// - [Fact] - public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var proxy = new FakeProxy(); - var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false }); - using var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - historian: null); - - var notifications = new ConcurrentQueue(); - backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s); - - await mx.ConnectAsync(); - await mx.DisconnectAsync(); - - notifications.Count.ShouldBe(2); - notifications.TryDequeue(out var first).ShouldBeTrue(); - first!.HostName.ShouldBe("GatewayClient"); - first.RuntimeStatus.ShouldBe("Running"); - first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0); - - notifications.TryDequeue(out var second).ShouldBeTrue(); - second!.HostName.ShouldBe("GatewayClient"); - second.RuntimeStatus.ShouldBe("Stopped"); - } - - [Fact] - public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events() - { - using var pump = new StaPump("Test.Sta"); - await pump.WaitForStartedAsync(); - var proxy = new FakeProxy(); - var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false }); - var backend = new MxAccessGalaxyBackend( - new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), - mx, - historian: null); - - var count = 0; - backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count); - - await mx.ConnectAsync(); - count.ShouldBe(1); - - backend.Dispose(); - await mx.DisconnectAsync(); - - count.ShouldBe(1); // no second notification after Dispose - } - - private sealed class FakeProxy : IMxProxy - { - private int _next = 1; - public int Register(string _) => 42; - public void Unregister(int _) { } - public int AddItem(int _, string __) => Interlocked.Increment(ref _next); - public void RemoveItem(int _, int __) { } - public void AdviseSupervisory(int _, int __) { } - public void UnAdviseSupervisory(int _, int __) { } - public void Write(int _, int __, object ___, int ____) { } - public event MxDataChangeHandler? OnDataChange { add { } remove { } } - public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/IpcHandshakeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/IpcHandshakeIntegrationTests.cs deleted file mode 100644 index 614ffa1..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/IpcHandshakeIntegrationTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.IO; -using System.IO.Pipes; -using System.Security.Principal; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using Serilog; -using Serilog.Core; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// Direct IPC handshake test — drives with a hand-rolled client - /// built on / from Shared. Stays in - /// net48 x86 alongside the Host (the Proxy's GalaxyIpcClient is net10 only and - /// cannot be loaded into this process). Functionally equivalent to going through - /// GalaxyIpcClient — proves the wire protocol + ACL + shared-secret enforcement. - /// - [Trait("Category", "Integration")] - public sealed class IpcHandshakeIntegrationTests - { - private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)> - ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct) - { - var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); - await stream.ConnectAsync(5_000, ct); - - var reader = new FrameReader(stream, leaveOpen: true); - var writer = new FrameWriter(stream, leaveOpen: true); - await writer.WriteAsync(MessageKind.Hello, - new Hello { PeerName = "test-client", SharedSecret = secret }, ct); - - var ack = await reader.ReadFrameAsync(ct); - if (ack is null) throw new EndOfStreamException("no HelloAck"); - if (ack.Value.Kind != MessageKind.HelloAck) throw new InvalidOperationException("unexpected first frame"); - var ackMsg = MessagePackSerializer.Deserialize(ack.Value.Body); - if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason); - - return (stream, reader, writer); - } - - [Fact] - public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips() - { - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!; - var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}"; - const string secret = "test-secret-2026"; - Logger log = new LoggerConfiguration().CreateLogger(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - var server = new PipeServer(pipe, sid, secret, log); - var serverTask = Task.Run(() => server.RunOneConnectionAsync( - new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token)); - - var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token); - using (stream) - using (reader) - using (writer) - { - await writer.WriteAsync(MessageKind.Heartbeat, - new Heartbeat { SequenceNumber = 42, UtcUnixMs = 1000 }, cts.Token); - - var hbAckFrame = await reader.ReadFrameAsync(cts.Token); - hbAckFrame.HasValue.ShouldBeTrue(); - hbAckFrame!.Value.Kind.ShouldBe(MessageKind.HeartbeatAck); - MessagePackSerializer.Deserialize(hbAckFrame.Value.Body).SequenceNumber.ShouldBe(42L); - } - - cts.Cancel(); - try { await serverTask; } catch { /* shutdown */ } - server.Dispose(); - } - - [Fact] - public async Task Handshake_with_wrong_secret_is_rejected() - { - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!; - var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}"; - Logger log = new LoggerConfiguration().CreateLogger(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - var server = new PipeServer(pipe, sid, "real-secret", log); - var serverTask = Task.Run(() => server.RunOneConnectionAsync( - new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token)); - - await Should.ThrowAsync(async () => - { - var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token); - s.Dispose(); - r.Dispose(); - w.Dispose(); - }); - - cts.Cancel(); - try { await serverTask; } catch { /* shutdown */ } - server.Dispose(); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs deleted file mode 100644 index faaa094..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class MemoryWatchdogTests -{ - private const long Mb = 1024 * 1024; - - [Fact] - public void Baseline_sample_returns_None() - { - var w = new MemoryWatchdog(baselineBytes: 300 * Mb); - w.Sample(320 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None); - } - - [Fact] - public void Warn_threshold_uses_larger_of_1_5x_or_plus_200MB() - { - // Baseline 300 → warn threshold = max(450, 500) = 500 MB - var w = new MemoryWatchdog(baselineBytes: 300 * Mb); - w.Sample(499 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None); - w.Sample(500 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn); - } - - [Fact] - public void Soft_recycle_triggers_at_2x_or_plus_200MB_whichever_larger() - { - // Baseline 400 → soft = max(800, 600) = 800 MB - var w = new MemoryWatchdog(baselineBytes: 400 * Mb); - w.Sample(799 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn); - w.Sample(800 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.SoftRecycle); - } - - [Fact] - public void Hard_kill_triggers_at_absolute_ceiling() - { - var w = new MemoryWatchdog(baselineBytes: 1000 * Mb); - w.Sample(1501 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.HardKill); - } - - [Fact] - public void Sustained_slope_triggers_soft_recycle_before_absolute_threshold() - { - // Baseline 1000 MB → warn = 1200, soft = 2000 (absolute). Slope 6 MB/min over 30 min = 180 MB - // delta — still well below the absolute soft threshold; slope detector must fire on its own. - var w = new MemoryWatchdog(baselineBytes: 1000 * Mb) { SustainedSlopeBytesPerMinute = 5 * Mb }; - var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); - - long rss = 1050 * Mb; - var slopeFired = false; - for (var i = 0; i <= 35; i++) - { - var action = w.Sample(rss, t0.AddMinutes(i)); - if (action == WatchdogAction.SoftRecycle) { slopeFired = true; break; } - rss += 6 * Mb; - } - - slopeFired.ShouldBeTrue("slope detector should fire once the 30-min window fills"); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessClientMonitorLoopTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessClientMonitorLoopTests.cs deleted file mode 100644 index c071788..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessClientMonitorLoopTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class MxAccessClientMonitorLoopTests -{ - /// - /// PR 6 low finding #1 — every $Heartbeat probe must RemoveItem the item handle it - /// allocated. Without that, the monitor leaks one handle per MonitorInterval seconds, - /// which over a 24h uptime becomes thousands of leaked MXAccess handles and can - /// eventually exhaust the runtime proxy's handle table. - /// - [Fact] - public async Task Heartbeat_probe_calls_RemoveItem_for_every_AddItem() - { - using var pump = new StaPump("Monitor.Sta"); - await pump.WaitForStartedAsync(); - - var proxy = new CountingProxy(); - var client = new MxAccessClient(pump, proxy, "probe-test", new MxAccessClientOptions - { - AutoReconnect = true, - MonitorInterval = TimeSpan.FromMilliseconds(150), - StaleThreshold = TimeSpan.FromMilliseconds(50), - }); - - await client.ConnectAsync(); - - // Wait past StaleThreshold, then let several monitor cycles fire. - await Task.Delay(700); - - client.Dispose(); - - // One Heartbeat probe fires per monitor tick once the connection looks stale. - proxy.HeartbeatAddCount.ShouldBeGreaterThan(1); - // Every AddItem("$Heartbeat") must be matched by a RemoveItem on the same handle. - proxy.HeartbeatAddCount.ShouldBe(proxy.HeartbeatRemoveCount); - proxy.OutstandingHeartbeatHandles.ShouldBe(0); - } - - /// - /// PR 6 low finding #2 — after reconnect, per-subscription replay failures must raise - /// SubscriptionReplayFailed so the backend can propagate the degradation, not get - /// silently eaten. - /// - [Fact] - public async Task SubscriptionReplayFailed_fires_for_each_tag_that_fails_to_replay() - { - using var pump = new StaPump("Replay.Sta"); - await pump.WaitForStartedAsync(); - - var proxy = new ReplayFailingProxy(failOnReplayForTags: new[] { "BadTag.A", "BadTag.B" }); - var client = new MxAccessClient(pump, proxy, "replay-test", new MxAccessClientOptions - { - AutoReconnect = true, - MonitorInterval = TimeSpan.FromMilliseconds(120), - StaleThreshold = TimeSpan.FromMilliseconds(50), - }); - - var failures = new ConcurrentBag(); - client.SubscriptionReplayFailed += (_, e) => failures.Add(e); - - await client.ConnectAsync(); - await client.SubscribeAsync("GoodTag.X", (_, _) => { }); - await client.SubscribeAsync("BadTag.A", (_, _) => { }); - await client.SubscribeAsync("BadTag.B", (_, _) => { }); - - proxy.TriggerProbeFailureOnNextCall(); - - // Wait for the monitor loop to probe → fail → reconnect → replay. - await Task.Delay(800); - - client.Dispose(); - - failures.Count.ShouldBe(2); - var names = new HashSet(); - foreach (var f in failures) names.Add(f.TagReference); - names.ShouldContain("BadTag.A"); - names.ShouldContain("BadTag.B"); - } - - // ----- test doubles ----- - - private sealed class CountingProxy : IMxProxy - { - private int _next = 1; - private readonly ConcurrentDictionary _live = new(); - - public int HeartbeatAddCount; - public int HeartbeatRemoveCount; - public int OutstandingHeartbeatHandles => _live.Count; - - public event MxDataChangeHandler? OnDataChange { add { } remove { } } - public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } } - - public int Register(string _) => 42; - public void Unregister(int _) { } - - public int AddItem(int _, string address) - { - var h = Interlocked.Increment(ref _next); - _live[h] = address; - if (address == "$Heartbeat") Interlocked.Increment(ref HeartbeatAddCount); - return h; - } - - public void RemoveItem(int _, int itemHandle) - { - if (_live.TryRemove(itemHandle, out var addr) && addr == "$Heartbeat") - Interlocked.Increment(ref HeartbeatRemoveCount); - } - - public void AdviseSupervisory(int _, int __) { } - public void UnAdviseSupervisory(int _, int __) { } - public void Write(int _, int __, object ___, int ____) { } - } - - /// - /// Mock that lets us exercise the reconnect + replay path. TriggerProbeFailureOnNextCall - /// flips a one-shot flag so the very next AddItem("$Heartbeat") throws — that drives the - /// monitor loop into the reconnect-with-replay branch. During the replay, AddItem for the - /// tags listed in failOnReplayForTags throws so SubscriptionReplayFailed should fire once - /// per failing tag. - /// - private sealed class ReplayFailingProxy : IMxProxy - { - private int _next = 1; - private readonly HashSet _failOnReplay; - private int _probeFailOnce; - private readonly ConcurrentDictionary _replayedOnce = new(StringComparer.OrdinalIgnoreCase); - - public ReplayFailingProxy(IEnumerable failOnReplayForTags) - { - _failOnReplay = new HashSet(failOnReplayForTags, StringComparer.OrdinalIgnoreCase); - } - - public void TriggerProbeFailureOnNextCall() => Interlocked.Exchange(ref _probeFailOnce, 1); - - public event MxDataChangeHandler? OnDataChange { add { } remove { } } - public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } } - - public int Register(string _) => 42; - public void Unregister(int _) { } - - public int AddItem(int _, string address) - { - if (address == "$Heartbeat" && Interlocked.Exchange(ref _probeFailOnce, 0) == 1) - throw new InvalidOperationException("simulated probe failure"); - - // Fail only on the *replay* AddItem for listed tags — not the initial subscribe. - if (_failOnReplay.Contains(address) && _replayedOnce.ContainsKey(address)) - throw new InvalidOperationException($"simulated replay failure for {address}"); - - if (_failOnReplay.Contains(address)) _replayedOnce[address] = true; - return Interlocked.Increment(ref _next); - } - - public void RemoveItem(int _, int __) { } - public void AdviseSupervisory(int _, int __) { } - public void UnAdviseSupervisory(int _, int __) { } - public void Write(int _, int __, object ___, int ____) { } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessLiveSmokeTests.cs deleted file mode 100644 index 56e3b52..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MxAccessLiveSmokeTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests -{ - /// - /// End-to-end smoke against the live MXAccess COM runtime + Galaxy ZB DB on this dev box. - /// Skipped when ArchestrA bootstrap (aaBootstrap) isn't running. Verifies the - /// ported can connect to LMXProxyServer, the - /// can answer Discover against the live ZB schema, - /// and a one-shot read returns a valid VTQ for the first deployed attribute it finds. - /// - [Trait("Category", "LiveMxAccess")] - public sealed class MxAccessLiveSmokeTests - { - private static GalaxyRepositoryOptions DevZb() => new() - { - ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;", - CommandTimeoutSeconds = 10, - }; - - private static async Task ArchestraReachableAsync() - { - try - { - var repo = new GalaxyRepository(DevZb()); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - if (!await repo.TestConnectionAsync(cts.Token)) return false; - - using var sc = new System.ServiceProcess.ServiceController("aaBootstrap"); - return sc.Status == System.ServiceProcess.ServiceControllerStatus.Running; - } - catch { return false; } - } - - [Fact] - public async Task Connect_to_local_LMXProxyServer_succeeds() - { - if (!await ArchestraReachableAsync()) return; - - using var pump = new StaPump("MxA-test-pump"); - await pump.WaitForStartedAsync(); - - using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke"); - var handle = await mx.ConnectAsync(); - handle.ShouldBeGreaterThan(0); - mx.IsConnected.ShouldBeTrue(); - } - - [Fact] - public async Task Backend_OpenSession_then_Discover_returns_objects_with_attributes() - { - if (!await ArchestraReachableAsync()) return; - - using var pump = new StaPump("MxA-test-pump"); - await pump.WaitForStartedAsync(); - using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke"); - var backend = new MxAccessGalaxyBackend(new GalaxyRepository(DevZb()), mx); - - var session = await backend.OpenSessionAsync(new OpenSessionRequest { DriverInstanceId = "smoke" }, CancellationToken.None); - session.Success.ShouldBeTrue(session.Error); - - var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = session.SessionId }, CancellationToken.None); - resp.Success.ShouldBeTrue(resp.Error); - resp.Objects.Length.ShouldBeGreaterThan(0); - - await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None); - } - - /// - /// Live one-shot read against any attribute we discover. Best-effort — passes silently - /// if no readable attribute is exposed (some Galaxy installs are configuration-only; - /// we only assert the call shape is correct, not a specific value). - /// - [Fact] - public async Task Backend_ReadValues_against_discovered_attribute_returns_a_response_shape() - { - if (!await ArchestraReachableAsync()) return; - - using var pump = new StaPump("MxA-test-pump"); - await pump.WaitForStartedAsync(); - using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke"); - var backend = new MxAccessGalaxyBackend(new GalaxyRepository(DevZb()), mx); - - var session = await backend.OpenSessionAsync(new OpenSessionRequest { DriverInstanceId = "smoke" }, CancellationToken.None); - var disc = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = session.SessionId }, CancellationToken.None); - var firstAttr = System.Linq.Enumerable.FirstOrDefault(disc.Objects, o => o.Attributes.Length > 0); - if (firstAttr is null) - { - await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None); - return; - } - - var fullRef = $"{firstAttr.TagName}.{firstAttr.Attributes[0].AttributeName}"; - var read = await backend.ReadValuesAsync( - new ReadValuesRequest { SessionId = session.SessionId, TagReferences = new[] { fullRef } }, - CancellationToken.None); - - read.Success.ShouldBeTrue(); - read.Values.Length.ShouldBe(1); - // We don't assert the value (it may be Bad/Uncertain depending on what's running); - // we only assert the response shape is correct end-to-end. - read.Values[0].TagReference.ShouldBe(fullRef); - - await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs deleted file mode 100644 index aa3aa34..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.IO; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class PostMortemMmfTests : IDisposable -{ - private readonly string _path = Path.Combine(Path.GetTempPath(), $"mmf-test-{Guid.NewGuid():N}.bin"); - - public void Dispose() - { - if (File.Exists(_path)) File.Delete(_path); - } - - [Fact] - public void Write_then_read_round_trips_entries_in_oldest_first_order() - { - using (var mmf = new PostMortemMmf(_path, capacity: 10)) - { - mmf.Write(0x30, "read tag-1"); - mmf.Write(0x30, "read tag-2"); - mmf.Write(0x32, "write tag-3"); - } - - using var reopen = new PostMortemMmf(_path, capacity: 10); - var entries = reopen.ReadAll(); - entries.Length.ShouldBe(3); - entries[0].Message.ShouldBe("read tag-1"); - entries[1].Message.ShouldBe("read tag-2"); - entries[2].Message.ShouldBe("write tag-3"); - entries[0].OpKind.ShouldBe(0x30L); - } - - [Fact] - public void Ring_buffer_wraps_and_oldest_entry_is_overwritten() - { - using var mmf = new PostMortemMmf(_path, capacity: 3); - mmf.Write(1, "A"); - mmf.Write(2, "B"); - mmf.Write(3, "C"); - mmf.Write(4, "D"); // overwrites A - - var entries = mmf.ReadAll(); - entries.Length.ShouldBe(3); - entries[0].Message.ShouldBe("B"); - entries[1].Message.ShouldBe("C"); - entries[2].Message.ShouldBe("D"); - } - - [Fact] - public void Message_longer_than_capacity_is_truncated_safely() - { - using var mmf = new PostMortemMmf(_path, capacity: 2); - var huge = new string('x', 500); - mmf.Write(0, huge); - - var entries = mmf.ReadAll(); - entries[0].Message.Length.ShouldBeLessThan(PostMortemMmf.EntryBytes); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs deleted file mode 100644 index 263c841..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class RecyclePolicyTests -{ - [Fact] - public void First_soft_recycle_is_allowed() - { - var p = new RecyclePolicy(); - p.TryRequestSoftRecycle(DateTime.UtcNow, out var reason).ShouldBeTrue(); - reason.ShouldBeNull(); - } - - [Fact] - public void Second_soft_recycle_within_cap_is_blocked() - { - var p = new RecyclePolicy(); - var t0 = DateTime.UtcNow; - p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue(); - p.TryRequestSoftRecycle(t0.AddMinutes(30), out var reason).ShouldBeFalse(); - reason.ShouldContain("frequency cap"); - } - - [Fact] - public void Recycle_after_cap_elapses_is_allowed_again() - { - var p = new RecyclePolicy(); - var t0 = DateTime.UtcNow; - p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue(); - p.TryRequestSoftRecycle(t0.AddHours(1).AddMinutes(1), out _).ShouldBeTrue(); - } - - [Fact] - public void Scheduled_recycle_fires_once_per_day_at_local_3am() - { - var p = new RecyclePolicy(); - var last = DateTime.MinValue; - - p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 2, 59, 0), ref last).ShouldBeFalse(); - p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 0, 0), ref last).ShouldBeTrue(); - p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 30, 0), ref last).ShouldBeFalse( - "already fired today"); - p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 18, 3, 0, 0), ref last).ShouldBeTrue( - "next day fires again"); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs deleted file mode 100644 index 9510cc6..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; - -[Trait("Category", "Unit")] -public sealed class StaPumpTests -{ - [Fact] - public async Task InvokeAsync_runs_work_on_the_STA_thread() - { - using var pump = new StaPump(); - await pump.WaitForStartedAsync(); - - var apartment = await pump.InvokeAsync(() => Thread.CurrentThread.GetApartmentState()); - apartment.ShouldBe(ApartmentState.STA); - } - - [Fact] - public async Task Responsiveness_probe_returns_true_under_healthy_pump() - { - using var pump = new StaPump(); - await pump.WaitForStartedAsync(); - - (await pump.IsResponsiveAsync(TimeSpan.FromSeconds(2))).ShouldBeTrue(); - } - - [Fact] - public async Task Responsiveness_probe_returns_false_when_pump_is_wedged() - { - using var pump = new StaPump(); - await pump.WaitForStartedAsync(); - - // Wedge the pump with an infinite work item on the STA thread. - var wedge = new ManualResetEventSlim(); - _ = pump.InvokeAsync(() => wedge.Wait()); - - var responsive = await pump.IsResponsiveAsync(TimeSpan.FromMilliseconds(500)); - responsive.ShouldBeFalse(); - - wedge.Set(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj deleted file mode 100644 index 60aadf2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - net48 - x86 - true - enable - latest - false - true - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - ..\..\lib\ArchestrA.MxAccess.dll - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/AlarmTransitionParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/AlarmTransitionParityTests.cs deleted file mode 100644 index 4f2d38f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/AlarmTransitionParityTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.5 — Alarm-condition + transition parity. Both backends discover the -/// same set of alarm-bearing attributes with matching -/// metadata; transition events from a live alarm flap must arrive with matching -/// severity, message, and source-node-id on each side. -/// -/// -/// Alarm-event persistence parity (the SQLite store-and-forward → Wonderware -/// historian event store path called out in the impl plan) is exercised -/// end-to-end in PR 5.6 against the historian sidecar; here we focus on the -/// in-process transition stream that emits. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class AlarmTransitionParityTests -{ - private readonly ParityHarness _h; - public AlarmTransitionParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Discover_emits_same_AlarmConditionInfo_per_alarm_attribute() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - return b.AlarmConditions.ToDictionary( - ac => ac.SourceNodeId, - ac => ac.Info, - StringComparer.OrdinalIgnoreCase); - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - if (legacy.Count == 0) - { - Assert.Skip("dev Galaxy has no alarm-marked attributes — alarm parity unverified for this rig"); - } - - legacy.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase) - .ShouldBe(mxgw.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase), - "alarm source-node-id set must match across backends"); - - foreach (var kvp in legacy) - { - mxgw[kvp.Key].InitialSeverity.ShouldBe(kvp.Value.InitialSeverity, - $"alarm severity parity for '{kvp.Key}'"); - mxgw[kvp.Key].SourceName.ShouldBe(kvp.Value.SourceName, - $"alarm SourceName parity for '{kvp.Key}'"); - - // PR 2.1 added the five sub-attribute refs (InAlarmRef / PriorityRef / - // DescAttrNameRef / AckedRef / AckMsgWriteRef) so the new server-side - // AlarmConditionService can subscribe + ack-write without help from the - // driver. The new mxgw GalaxyDriver populates them via AlarmRefBuilder - // (PR 4.1). The legacy GalaxyProxyDriver pre-dates PR 2.1 and leaves them - // null — that's an accepted delta until the legacy backend retires in - // PR 7.2. Asserting "mxgw populated when legacy didn't" is *correct* - // behavior, not a regression. - // - // We pin the weaker invariant: if legacy populated a ref, mxgw must - // populate the same value. If legacy is null, mxgw is allowed to be - // either null or populated (the population-from-AlarmRefBuilder direction). - if (kvp.Value.InAlarmRef is not null) - { - mxgw[kvp.Key].InAlarmRef.ShouldBe(kvp.Value.InAlarmRef, - $"alarm InAlarmRef parity for '{kvp.Key}' (both populated)"); - } - if (kvp.Value.DescAttrNameRef is not null) - { - mxgw[kvp.Key].DescAttrNameRef.ShouldBe(kvp.Value.DescAttrNameRef, - $"alarm DescAttrNameRef parity for '{kvp.Key}' (both populated)"); - } - } - } - - [Fact] - public async Task Discover_marks_at_least_one_alarm_attribute_when_dev_Galaxy_has_alarms() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - return b.Variables.Count(v => v.AttributeInfo.IsAlarm); - }, CancellationToken.None); - - // Soft pin — count must match across backends. Whether the count is non-zero - // depends on the rig's Galaxy content, so we don't gate on a positive number. - snapshots[ParityHarness.Backend.LegacyHost] - .ShouldBe(snapshots[ParityHarness.Backend.MxGateway], - "IsAlarm-marked variable count parity"); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs deleted file mode 100644 index dd02a12..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.2 — Browse + read parity. Discovers the address space through both -/// backends and asserts the surface they expose matches: same folder set, -/// same variable set, same DataType / SecurityClass / IsHistorized flags. -/// Then reads a sample of resolved variables and diffs the snapshot triplets. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class BrowseAndReadParityTests -{ - private readonly ParityHarness _h; - public BrowseAndReadParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Discover_emits_same_variable_set_for_both_backends() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - return b; - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - var legacyRefs = legacy.Variables.Select(v => v.AttributeInfo.FullName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var mxgwRefs = mxgw.Variables.Select(v => v.AttributeInfo.FullName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Symmetric difference must be empty — the in-process driver and the legacy - // proxy walk the same Galaxy ZB hierarchy, so their full-reference sets - // must agree exactly. - legacyRefs.Except(mxgwRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(); - mxgwRefs.Except(legacyRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(); - } - - [Fact] - public async Task Discover_emits_same_DataType_and_SecurityClass_per_attribute() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - return b.Variables.ToDictionary( - v => v.AttributeInfo.FullName, - v => (v.AttributeInfo.DriverDataType, v.AttributeInfo.SecurityClass, v.AttributeInfo.IsHistorized), - StringComparer.OrdinalIgnoreCase); - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - foreach (var kvp in legacy) - { - var fullRef = kvp.Key; - mxgw.ShouldContainKey(fullRef); - mxgw[fullRef].ShouldBe(kvp.Value, - $"DataType/SecurityClass/IsHistorized must match for '{fullRef}'"); - } - } - - [Fact] - public async Task Read_returns_same_value_and_status_for_a_sampled_attribute() - { - _h.RequireBoth(); - - // Discover via the legacy backend, pick a sample, then read the same address - // through both backends. We sample a small handful so the test stays fast and - // doesn't hammer ZB / the gateway. - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); - - var sample = b.Variables.Take(5).Select(v => v.AttributeInfo.FullName).ToArray(); - if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); - - var reads = await _h.RunOnAvailableAsync( - (driver, ct) => ((IReadable)driver).ReadAsync(sample, ct), - CancellationToken.None); - - var legacyReads = reads[ParityHarness.Backend.LegacyHost]; - var mxgwReads = reads[ParityHarness.Backend.MxGateway]; - - legacyReads.Count.ShouldBe(sample.Length); - mxgwReads.Count.ShouldBe(sample.Length); - - for (var i = 0; i < sample.Length; i++) - { - // StatusCode must agree on the same status *class* (Good / Uncertain / Bad). - // Per Galaxy.ParityMatrix.md "Accepted deltas", legacy and mxgw map - // MxAccess HRESULTs to different exact OPC UA codes — pinning the class - // is the parity invariant. - (legacyReads[i].StatusCode & 0xC0000000u) - .ShouldBe(mxgwReads[i].StatusCode & 0xC0000000u, - $"StatusCode class parity for '{sample[i]}': legacy=0x{legacyReads[i].StatusCode:X8}, mxgw=0x{mxgwReads[i].StatusCode:X8}"); - - // Value-CLR-type parity is intentionally NOT asserted. Legacy returns the - // raw VARIANT (e.g. byte[]) for an attribute that hasn't received its first - // value cycle from MxAccess yet, while mxgw returns the typed value - // (Float, Int32, etc.) — and both null-vs-typed combinations occur on a - // live galaxy. The status-class assertion above pins the parity invariant - // that *matters* (Bad-vs-Good). The encoding-specific CLR type isn't - // load-bearing for the parity gate. Accepted delta — see - // Galaxy.ParityMatrix.md. - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs deleted file mode 100644 index c5d01d3..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// Shape tests for the itself — these run regardless of -/// dev-environment availability. The scenario tests in PR 5.2–5.8 carry the actual -/// parity assertions and are guarded by . -/// -[Collection(nameof(ParityCollection))] -public sealed class HarnessShapeTests -{ - private readonly ParityHarness _h; - public HarnessShapeTests(ParityHarness h) => _h = h; - - [Fact] - public void Harness_records_a_skip_reason_for_each_unavailable_backend() - { - // Either the backend resolved (driver != null, skipReason == null) or it didn't - // (driver == null, skipReason populated). Asserting the invariant lets the parity - // matrix doc (PR 5.W) faithfully report "n/a, reason: ..." for unreachable rigs. - (_h.LegacyDriver is null).ShouldBe(_h.LegacySkipReason is not null); - (_h.MxGatewayDriver is null).ShouldBe(_h.MxGatewaySkipReason is not null); - } - - [Fact] - public async Task RunOnAvailableAsync_yields_one_entry_per_resolved_backend() - { - var calls = await _h.RunOnAvailableAsync( - (_, _) => Task.FromResult(1), CancellationToken.None); - - var expected = (_h.LegacyDriver is null ? 0 : 1) + (_h.MxGatewayDriver is null ? 0 : 1); - calls.Count.ShouldBe(expected); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs deleted file mode 100644 index dc570fa..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.6 — History-read parity. Phase-1 routing lifted history off the -/// per-driver path onto the server-owned -/// HistoryRouter + WonderwareHistorianBootstrap; neither -/// Galaxy backend implements directly. So -/// the parity surface here is the *routing decision*: both backends must -/// identify the same set of historized attributes and produce the same -/// full-reference for each, so HistoryRouter routes reads identically. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class HistoryReadParityTests -{ - private readonly ParityHarness _h; - public HistoryReadParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Discover_emits_same_historized_attribute_set_for_both_backends() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - return b.Variables - .Where(v => v.AttributeInfo.IsHistorized) - .Select(v => v.AttributeInfo.FullName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - if (legacy.Count == 0) - { - Assert.Skip("dev Galaxy has no historized attributes — history routing parity unverified for this rig"); - } - - legacy.Except(mxgw, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( - "every historized attribute discovered by the legacy backend must appear in the mxgw backend"); - mxgw.Except(legacy, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( - "every historized attribute discovered by the mxgw backend must appear in the legacy backend"); - } - - [Fact] - public async Task The_new_Galaxy_backend_does_not_implement_IHistoryProvider_directly() - { - // Pinning the architectural decision from Phase 1 (PR 1.3): per-driver - // IHistoryProvider was retired in favor of the server-owned HistoryRouter - // for the *new* in-process GalaxyDriver. The legacy GalaxyProxyDriver - // still surfaces IHistoryProvider for back-compat with the legacy server - // bootstrap path (it's an accepted delta — the legacy driver retires in - // PR 7.2 alongside the rest of the legacy projects). The architectural - // pin we want to enforce is "the *new* path doesn't regress to per-driver - // history". - _h.RequireBoth(); - - (_h.MxGatewayDriver as IHistoryProvider).ShouldBeNull( - "in-process GalaxyDriver must not surface IHistoryProvider — history routes through HistoryRouter"); - await Task.CompletedTask; - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs deleted file mode 100644 index 0b46898..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System.Diagnostics; -using System.Net.Sockets; -using System.Reflection; -using System.Security.Principal; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// Side-by-side fixture that drives both the legacy -/// (talking to an out-of-process OtOpcUa.Driver.Galaxy.Host.exe) and the new -/// in-process (talking to a running mxaccessgw -/// gateway) against the same dev Galaxy. Phase 5 scenario tests use this harness -/// to capture comparable snapshots from each backend. -/// -/// -/// Per-backend availability is independent — a developer running just the legacy -/// Galaxy.Host EXE without an mxaccessgw process up will see the legacy driver -/// resolve and the mxgw driver mark itself unavailable. Each test decides how to -/// handle partial availability: -/// -/// Strict-parity tests call to skip when either side -/// is missing. -/// Single-backend smoke tests call for the backend they -/// care about and skip with the recorded SkipReason. -/// -/// Endpoint overrides come from environment variables so dev VMs and the central -/// parity host can target the same suite without touching the test source: -/// -/// OTOPCUA_PARITY_GW_ENDPOINT — defaults to http://localhost:5120 -/// (mxaccessgw launchSettings.json http profile). -/// OTOPCUA_PARITY_GW_API_KEY — defaults to parity-suite-key. -/// OTOPCUA_PARITY_CLIENT_NAME — defaults to OtOpcUa-Parity. -/// -/// -public sealed class ParityHarness : IAsyncLifetime -{ - public enum Backend { LegacyHost, MxGateway } - - private const string LegacySecret = "parity-suite-secret"; - private const string DefaultGwEndpoint = "http://localhost:5120"; - private const string DefaultGwApiKey = "parity-suite-key"; - private const string DefaultClientName = "OtOpcUa-Parity"; - - public IDriver? LegacyDriver { get; private set; } - public string? LegacySkipReason { get; private set; } - - public IDriver? MxGatewayDriver { get; private set; } - public string? MxGatewaySkipReason { get; private set; } - - private Process? _legacyHost; - - public async ValueTask InitializeAsync() - { - if (!OperatingSystem.IsWindows()) - { - LegacySkipReason = "Windows-only"; - MxGatewaySkipReason = "Windows-only"; - return; - } - - await InitializeLegacyAsync(); - await InitializeMxGatewayAsync(); - } - - public async ValueTask DisposeAsync() - { - // Independent teardown — failure on one side must not prevent the other from - // releasing its resources (esp. the legacy Host EXE subprocess). - if (LegacyDriver is not null) - { - try { await LegacyDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ } - (LegacyDriver as IDisposable)?.Dispose(); - LegacyDriver = null; - } - if (_legacyHost is not null && !_legacyHost.HasExited) - { - try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ } - try { _legacyHost.WaitForExit(5_000); } catch { /* ignore */ } - } - _legacyHost?.Dispose(); - _legacyHost = null; - - if (MxGatewayDriver is not null) - { - try { await MxGatewayDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ } - (MxGatewayDriver as IDisposable)?.Dispose(); - MxGatewayDriver = null; - } - } - - /// Skip the test if either backend isn't available — strict-parity scenarios. - public void RequireBoth() - { - if (LegacySkipReason is not null) Assert.Skip($"legacy backend unavailable: {LegacySkipReason}"); - if (MxGatewaySkipReason is not null) Assert.Skip($"mxgateway backend unavailable: {MxGatewaySkipReason}"); - } - - /// Get a backend driver or skip if it's unavailable. - public IDriver GetDriver(Backend backend) - { - return backend switch - { - Backend.LegacyHost when LegacyDriver is not null => LegacyDriver, - Backend.LegacyHost => SkipAndThrow($"legacy backend unavailable: {LegacySkipReason}"), - Backend.MxGateway when MxGatewayDriver is not null => MxGatewayDriver, - Backend.MxGateway => SkipAndThrow($"mxgateway backend unavailable: {MxGatewaySkipReason}"), - _ => throw new ArgumentOutOfRangeException(nameof(backend), backend, null), - }; - } - - /// - /// Drive the same closure against every available backend. Tests use the - /// returned dictionary to diff snapshots — keys are the backends that - /// successfully resolved during . If neither - /// resolved, the result is empty and the test should skip. - /// - public async Task> RunOnAvailableAsync( - Func> scenario, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(scenario); - var results = new Dictionary(); - if (LegacyDriver is not null) - { - results[Backend.LegacyHost] = await scenario(LegacyDriver, cancellationToken).ConfigureAwait(false); - } - if (MxGatewayDriver is not null) - { - results[Backend.MxGateway] = await scenario(MxGatewayDriver, cancellationToken).ConfigureAwait(false); - } - return results; - } - - [System.Runtime.Versioning.SupportedOSPlatform("windows")] - private async Task InitializeLegacyAsync() - { - if (!await ZbReachableAsync()) - { - LegacySkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; - return; - } - var hostExe = FindLegacyHostExe(); - if (hostExe is null) - { - LegacySkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; - return; - } - - var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}"; - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!.Value; - - var psi = new ProcessStartInfo(hostExe) - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - EnvironmentVariables = - { - ["OTOPCUA_GALAXY_PIPE"] = pipe, - ["OTOPCUA_ALLOWED_SID"] = sid, - ["OTOPCUA_GALAXY_SECRET"] = LegacySecret, - // PR 5.W triage 2026-04-30: db-backend is Discover-only. The parity - // matrix needs Read / Write / Subscribe over a real MxAccess session, - // so use the mxaccess backend. ZB conn string is still consulted for - // the discovery path (the mxaccess backend layers MxAccess on top of - // the same DB). - ["OTOPCUA_GALAXY_BACKEND"] = "mxaccess", - ["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", - }, - }; - - try - { - _legacyHost = Process.Start(psi) - ?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE"); - await Task.Delay(2_000); // PipeServer warm-up — ParityFixture's settled value - - var driver = new GalaxyProxyDriver(new GalaxyProxyOptions - { - DriverInstanceId = "parity-legacy", - PipeName = pipe, - SharedSecret = LegacySecret, - ConnectTimeout = TimeSpan.FromSeconds(5), - }); - await driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); - LegacyDriver = driver; - } - catch (Exception ex) - { - LegacySkipReason = $"legacy backend boot failed: {ex.Message}"; - if (_legacyHost is not null && !_legacyHost.HasExited) - { - try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ } - } - } - } - - private async Task InitializeMxGatewayAsync() - { - var endpoint = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_ENDPOINT") ?? DefaultGwEndpoint; - var apiKey = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_API_KEY") ?? DefaultGwApiKey; - var clientName = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_CLIENT_NAME") ?? DefaultClientName; - - if (!await GwReachableAsync(endpoint)) - { - MxGatewaySkipReason = $"mxaccessgw not reachable at {endpoint}"; - return; - } - - var configJson = $$""" - { - "Gateway": { - "Endpoint": "{{endpoint}}", - "ApiKeySecretRef": "{{apiKey}}", - "UseTls": {{(endpoint.StartsWith("https") ? "true" : "false")}} - }, - "MxAccess": { "ClientName": "{{clientName}}" } - } - """; - - try - { - var driver = GalaxyDriverFactoryExtensions.CreateInstance("parity-mxgw", configJson); - await driver.InitializeAsync(configJson, CancellationToken.None); - MxGatewayDriver = driver; - } - catch (Exception ex) - { - MxGatewaySkipReason = $"mxgateway backend boot failed: {ex.GetType().Name}: {ex.Message}"; - } - } - - private static IDriver SkipAndThrow(string reason) - { - Assert.Skip(reason); - throw new UnreachableException(); // Assert.Skip throws SkipException; this satisfies the compiler - } - - private static async Task ZbReachableAsync() - { - try - { - using var client = new TcpClient(); - var task = client.ConnectAsync("localhost", 1433); - return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; - } - catch { return false; } - } - - private static async Task GwReachableAsync(string endpoint) - { - // Lightweight TCP probe — avoids spending the full gRPC connect timeout when the - // gateway just isn't running. We can't validate the API-key handshake here without - // doing a real RPC, so a successful TCP connect is the "available" signal and any - // auth/protocol failure surfaces during InitializeAsync below. - try - { - var uri = new Uri(endpoint, UriKind.Absolute); - using var client = new TcpClient(); - var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80); - var task = client.ConnectAsync(uri.Host, port); - return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; - } - catch { return false; } - } - - private static string? FindLegacyHostExe() - { - var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - var solutionRoot = asmDir; - for (var i = 0; i < 8 && solutionRoot is not null; i++) - { - if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break; - solutionRoot = Path.GetDirectoryName(solutionRoot); - } - if (solutionRoot is null) return null; - - var path = Path.Combine(solutionRoot, - "src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48", - "OtOpcUa.Driver.Galaxy.Host.exe"); - return File.Exists(path) ? path : null; - } -} - -[CollectionDefinition(nameof(ParityCollection))] -public sealed class ParityCollection : ICollectionFixture { } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ReconnectParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ReconnectParityTests.cs deleted file mode 100644 index 7b7beec..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ReconnectParityTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.7 — Reconnect / disruption parity. After -/// both backends must return to and continue serving -/// reads against the same Galaxy. Recovery time isn't pinned tightly because the -/// legacy proxy reconnects the named pipe + Galaxy.Host's MxAccess client while the -/// mxgw driver re-Registers the gateway session — different latencies are expected, -/// but both must converge. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class ReconnectParityTests -{ - private readonly ParityHarness _h; - public ReconnectParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Reinitialize_returns_both_backends_to_Healthy() - { - _h.RequireBoth(); - - // Capture an initial read off both backends so we have a comparison baseline. - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); - var sample = b.Variables.Take(3).Select(v => v.AttributeInfo.FullName).ToArray(); - if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); - - await _h.RunOnAvailableAsync(async (driver, ct) => - { - await driver.ReinitializeAsync(driverConfigJson: "{}", ct); - var health = driver.GetHealth(); - health.State.ShouldBe(DriverState.Healthy, - $"{driver.DriverType} must return to Healthy after Reinitialize"); - return health.State; - }, CancellationToken.None); - - // Reads must continue to succeed after reinit on both sides. - var reads = await _h.RunOnAvailableAsync( - (driver, ct) => ((IReadable)driver).ReadAsync(sample, ct), - CancellationToken.None); - - reads[ParityHarness.Backend.LegacyHost].Count.ShouldBe(sample.Length); - reads[ParityHarness.Backend.MxGateway].Count.ShouldBe(sample.Length); - } - - [Fact] - public async Task Health_state_diverges_only_when_one_backend_is_in_recovery() - { - _h.RequireBoth(); - - var legacyHealth = _h.LegacyDriver!.GetHealth().State; - var mxgwHealth = _h.MxGatewayDriver!.GetHealth().State; - - // Both backends were Healthy at end of InitializeAsync. If either has gone - // Degraded, that's a real issue — surface it directly. - legacyHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded); - mxgwHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded); - - // For now we don't pin them to be identical because the supervisor's - // sampling cadence differs between backends. The 5.7 follow-up scenario - // (when we introduce a toxiproxy-style fault injection) tightens this. - await Task.CompletedTask; - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs deleted file mode 100644 index f7309d9..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// Same shape as Driver.Galaxy.E2E.RecordingAddressSpaceBuilder; duplicated -/// here so the parity-tests project doesn't take a hard project reference on the -/// E2E project (which would double-register E2E test classes during discovery). -/// -public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder -{ - public List Folders { get; } = new(); - public List Variables { get; } = new(); - public List Properties { get; } = new(); - public List AlarmConditions { get; } = new(); - public List AlarmTransitions { get; } = new(); - - public IAddressSpaceBuilder Folder(string browseName, string displayName) - { - Folders.Add(new RecordedFolder(browseName, displayName)); - return this; - } - - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo)); - return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions, AlarmTransitions); - } - - public void AddProperty(string browseName, DriverDataType dataType, object? value) - => Properties.Add(new RecordedProperty(browseName, dataType, value)); - - public sealed record RecordedFolder(string BrowseName, string DisplayName); - public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo); - public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value); - public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info); - public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args); - - private sealed class RecordedVariableHandle( - string fullReference, - List conditions, - List transitions) : IVariableHandle - { - public string FullReference => fullReference; - - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) - { - conditions.Add(new RecordedAlarmCondition(fullReference, info)); - return new RecordingSink(fullReference, transitions); - } - - private sealed class RecordingSink( - string sourceNodeId, List transitions) : IAlarmConditionSink - { - public void OnTransition(AlarmEventArgs args) - => transitions.Add(new RecordedAlarmTransition(sourceNodeId, args)); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ScanStateProbeParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ScanStateProbeParityTests.cs deleted file mode 100644 index 9efad27..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ScanStateProbeParityTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.8 — Per-platform ScanState probe parity. The legacy backend's -/// GalaxyRuntimeProbeManager and the in-process backend's -/// PerPlatformProbeWatcher (PR 4.7) must surface the same per-host -/// stream after Discover: same host name -/// set, matching per host. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class ScanStateProbeParityTests -{ - private readonly ParityHarness _h; - public ScanStateProbeParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task GetHostStatuses_emits_same_host_set_after_Discover() - { - _h.RequireBoth(); - - // Probe-watcher membership only refreshes after a Discover pass — drive that - // first so both backends have populated their per-platform tracker. - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - // Give the probe watcher a beat to land its initial ScanState reads — - // PR 4.7 subscribes per platform with bufferedUpdateIntervalMs=0 so the - // first push lands within ~publishingInterval (1s default). - await Task.Delay(1_500, ct); - return ((IHostConnectivityProbe)driver).GetHostStatuses(); - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - // Legacy reports: client-name transport entry + every $WinPlatform/$AppEngine - // probe. Mxgw reports the same shape (PR 4.7). The host-name set must agree - // case-insensitively. - var legacyHosts = legacy.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase); - var mxgwHosts = mxgw.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase); - - if (legacyHosts.Count == 0) - { - Assert.Skip("legacy backend reported no host probes — dev Galaxy may not be a multi-platform deployment"); - } - - // The transport-entry host names differ by design — legacy uses the legacy - // host's process-level identity, mxgw uses MxAccess.ClientName. Compare - // only the platform-host subset (anything that's NOT either side's transport). - var legacyPlatformHosts = legacyHosts.Where(h => !h.Contains("Galaxy.Host", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase); - var mxgwPlatformHosts = mxgwHosts.Where(h => !h.Contains("OtOpcUa-Parity", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase); - - legacyPlatformHosts.Except(mxgwPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( - "every $WinPlatform / $AppEngine probed by the legacy backend must appear in the mxgw probe set"); - mxgwPlatformHosts.Except(legacyPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( - "every $WinPlatform / $AppEngine probed by the mxgw backend must appear in the legacy probe set"); - } - - [Fact] - public async Task GetHostStatuses_state_per_platform_matches_across_backends() - { - _h.RequireBoth(); - - var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) => - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, ct); - await Task.Delay(1_500, ct); - return ((IHostConnectivityProbe)driver).GetHostStatuses() - .ToDictionary(s => s.HostName, s => s.State, StringComparer.OrdinalIgnoreCase); - }, CancellationToken.None); - - var legacy = snapshots[ParityHarness.Backend.LegacyHost]; - var mxgw = snapshots[ParityHarness.Backend.MxGateway]; - - if (legacy.Count == 0 || mxgw.Count == 0) - { - Assert.Skip("one or both backends reported no host probes"); - } - - // Skip the transport entry per backend (different by design); compare the - // platform-host overlap. - var commonHosts = legacy.Keys.Intersect(mxgw.Keys, StringComparer.OrdinalIgnoreCase).ToArray(); - if (commonHosts.Length == 0) - { - Assert.Skip("no overlapping platform hosts between backends — likely the transport names differ but no $WinPlatform was discovered"); - } - - foreach (var host in commonHosts) - { - mxgw[host].ShouldBe(legacy[host], $"HostState parity for '{host}'"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SoakScenarioTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SoakScenarioTests.cs deleted file mode 100644 index fdb9867..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SoakScenarioTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 6.4 — long-running soak scenario for the in-process Galaxy driver against a -/// live mxaccessgw. Subscribes a configurable tag count, holds the subscription -/// for a configurable duration, polls the EventPump's three counters -/// (galaxy.events.received / galaxy.events.dispatched / -/// galaxy.events.dropped) every minute, and asserts: -/// -/// events.received continues to grow (the gw stream isn't stuck) -/// events.dropped stays under a configurable ceiling -/// process working-set size doesn't grow unboundedly (leak guard) -/// -/// Always skipped unless the operator opts in via OTOPCUA_SOAK_RUN=1 and -/// the mxgw backend is reachable. The default scenario size is 50k tags / 24h -/// per the PR plan; both are env-overridable so a smoke run can shorten them -/// to a few minutes for CI. -/// -[Trait("Category", "Soak")] -[Collection(nameof(ParityCollection))] -public sealed class SoakScenarioTests -{ - private const string MeterName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"; - - private readonly ParityHarness _h; - public SoakScenarioTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Soak_HoldsSubscription_AndKeepsEventStreamFlowing() - { - var run = Environment.GetEnvironmentVariable("OTOPCUA_SOAK_RUN"); - if (!string.Equals(run, "1", StringComparison.Ordinal)) - { - Assert.Skip("set OTOPCUA_SOAK_RUN=1 to run the 50k-tag soak (default 24h, override OTOPCUA_SOAK_MINUTES + OTOPCUA_SOAK_TAGS for CI)"); - } - if (_h.MxGatewayDriver is null) - { - Assert.Skip($"mxgateway backend unavailable: {_h.MxGatewaySkipReason}"); - } - - var tagCount = ParseInt("OTOPCUA_SOAK_TAGS", 50_000); - var soakMinutes = ParseInt("OTOPCUA_SOAK_MINUTES", 24 * 60); - var dropCeilingPercent = ParseDouble("OTOPCUA_SOAK_DROP_PCT", 0.5); // 0.5% drop ceiling - - // Discover and pick a sample. If the live Galaxy doesn't have tagCount tags, - // fall back to whatever's available — soak diagnostics still apply. - var driver = _h.MxGatewayDriver!; - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)driver).DiscoverAsync(b, CancellationToken.None); - - var sample = b.Variables.Take(tagCount) - .Select(v => v.AttributeInfo.FullName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (sample.Length == 0) Assert.Skip("dev Galaxy reported zero discoverable variables — nothing to soak"); - - // Capture the three EventPump counters via MeterListener so we can poll - // their cumulative totals once per minute. - var snapshot = new CounterSnapshot(); - using var listener = new MeterListener(); - listener.InstrumentPublished = (instr, l) => - { - if (instr.Meter.Name == MeterName) l.EnableMeasurementEvents(instr); - }; - listener.SetMeasurementEventCallback((instr, value, _, _) => - { - switch (instr.Name) - { - case "galaxy.events.received": Interlocked.Add(ref snapshot._received, value); break; - case "galaxy.events.dispatched": Interlocked.Add(ref snapshot._dispatched, value); break; - case "galaxy.events.dropped": Interlocked.Add(ref snapshot._dropped, value); break; - } - }); - listener.Start(); - - var initialWorkingSet = Process.GetCurrentProcess().WorkingSet64; - var startedUtc = DateTime.UtcNow; - var deadline = startedUtc + TimeSpan.FromMinutes(soakMinutes); - - var handle = await ((ISubscribable)driver) - .SubscribeAsync(sample, TimeSpan.FromSeconds(1), CancellationToken.None); - try - { - // Per-minute poll loop — pin the invariants and produce a CSV-style - // log row so an operator can grep the test runner's stdout. - var lastReceived = 0L; - while (DateTime.UtcNow < deadline) - { - await Task.Delay(TimeSpan.FromMinutes(1)); - var elapsed = DateTime.UtcNow - startedUtc; - var ws = Process.GetCurrentProcess().WorkingSet64; - Console.WriteLine( - $"soak,{elapsed.TotalMinutes:F1},received={snapshot.Received},dispatched={snapshot.Dispatched},dropped={snapshot.Dropped},ws_mb={ws / 1024 / 1024}"); - - snapshot.Received.ShouldBeGreaterThan(lastReceived, - $"events.received did not grow over the last minute (elapsed={elapsed:hh\\:mm\\:ss}) — gw stream may be stuck"); - lastReceived = snapshot.Received; - - var droppedPct = snapshot.Received == 0 - ? 0.0 - : 100.0 * snapshot.Dropped / snapshot.Received; - droppedPct.ShouldBeLessThan(dropCeilingPercent, - $"events.dropped ratio {droppedPct:F2}% exceeded {dropCeilingPercent:F2}% ceiling at {elapsed:hh\\:mm\\:ss}"); - - // Working-set guard: if the process grew >1 GB above the initial - // baseline, surface it. This is generous — a hot subscription stream - // legitimately uses memory; we're catching unbounded leaks, not - // steady-state allocation. - ((ws - initialWorkingSet) / (1024L * 1024L * 1024L)) - .ShouldBeLessThan(1L, - $"working set grew >1 GB above baseline at {elapsed:hh\\:mm\\:ss} — possible leak"); - } - } - finally - { - await ((ISubscribable)driver).UnsubscribeAsync(handle, CancellationToken.None); - } - } - - private static int ParseInt(string name, int defaultValue) => - int.TryParse(Environment.GetEnvironmentVariable(name), out var v) ? v : defaultValue; - private static double ParseDouble(string name, double defaultValue) => - double.TryParse(Environment.GetEnvironmentVariable(name), out var v) ? v : defaultValue; - - private sealed class CounterSnapshot - { - internal long _received, _dispatched, _dropped; - public long Received => Interlocked.Read(ref _received); - public long Dispatched => Interlocked.Read(ref _dispatched); - public long Dropped => Interlocked.Read(ref _dropped); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs deleted file mode 100644 index fe5955e..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.3 — Subscribe + event-rate parity. Both backends must accept the same -/// full-reference list, return a usable subscription handle, and dispatch a -/// similar number of OnDataChange events for the same observation window. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class SubscribeAndEventRateParityTests -{ - private readonly ParityHarness _h; - public SubscribeAndEventRateParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task Subscribe_returns_a_handle_for_each_backend() - { - _h.RequireBoth(); - - var sample = await PickSampleAsync(5); - if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); - - var handles = await _h.RunOnAvailableAsync( - (driver, ct) => ((ISubscribable)driver).SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), ct), - CancellationToken.None); - - handles[ParityHarness.Backend.LegacyHost].ShouldNotBeNull(); - handles[ParityHarness.Backend.MxGateway].ShouldNotBeNull(); - - // Clean up so we don't leave dangling advises in either backend. - foreach (var (backend, handle) in handles) - { - await ((ISubscribable)_h.GetDriver(backend)) - .UnsubscribeAsync(handle, CancellationToken.None); - } - } - - [Fact] - public async Task Subscribe_event_rate_within_tolerance_for_a_3s_window() - { - _h.RequireBoth(); - - var sample = await PickSampleAsync(5); - if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); - - var counts = new Dictionary(); - var subs = new Dictionary(); - try - { - foreach (var backend in new[] { ParityHarness.Backend.LegacyHost, ParityHarness.Backend.MxGateway }) - { - var driver = _h.GetDriver(backend); - var local = 0; - EventHandler handler = (_, _) => Interlocked.Increment(ref local); - ((ISubscribable)driver).OnDataChange += handler; - var handle = await ((ISubscribable)driver) - .SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), CancellationToken.None); - subs[backend] = handle; - - await Task.Delay(3_000, TestContext.Current.CancellationToken); - - ((ISubscribable)driver).OnDataChange -= handler; - counts[backend] = Volatile.Read(ref local); - } - - // Tolerance is generous because both backends are looking at the same - // physical Galaxy; the gateway's StreamEvents pump and the legacy - // OnDataChange COM advises are fed by the same MXAccess subscriptions - // upstream. ±50% absorbs scheduler jitter without hiding a wholesale - // event-rate regression. - var legacyCount = counts[ParityHarness.Backend.LegacyHost]; - var mxgwCount = counts[ParityHarness.Backend.MxGateway]; - if (legacyCount + mxgwCount == 0) - { - Assert.Skip("no value changes observed in 3s window — sample may be all static configuration tags"); - } - var ratio = (double)mxgwCount / Math.Max(legacyCount, 1); - ratio.ShouldBeInRange(0.5, 1.5, - $"event-rate parity within ±50%: legacy={legacyCount}, mxgw={mxgwCount}"); - } - finally - { - foreach (var (backend, handle) in subs) - { - try - { - await ((ISubscribable)_h.GetDriver(backend)) - .UnsubscribeAsync(handle, CancellationToken.None); - } - catch { /* best-effort cleanup */ } - } - } - } - - private async Task PickSampleAsync(int count) - { - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); - return b.Variables.Take(count).Select(v => v.AttributeInfo.FullName).ToArray(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs deleted file mode 100644 index 3227ed3..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; - -/// -/// PR 5.4 — Write-by-classification parity. Each driver routes writes by the -/// attribute's : FreeAccess / -/// Operate use plain Write; Tune / Configure / -/// VerifiedWrite use WriteSecured. Both backends must surface the -/// same StatusCode for the same write request — successful for FreeAccess / -/// Operate (assuming the dev Galaxy has at least one writable attribute) and -/// failure for Configure when no auth principal is supplied. -/// -[Trait("Category", "ParityE2E")] -[Collection(nameof(ParityCollection))] -public sealed class WriteByClassificationParityTests -{ - private readonly ParityHarness _h; - public WriteByClassificationParityTests(ParityHarness h) => _h = h; - - [Fact] - public async Task FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends() - { - _h.RequireBoth(); - - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); - - var target = b.Variables.FirstOrDefault(v => - v.AttributeInfo.SecurityClass is SecurityClassification.FreeAccess or SecurityClassification.Operate - && v.AttributeInfo.DriverDataType is DriverDataType.Float32 or DriverDataType.Float64 or DriverDataType.Int32); - if (target is null) Assert.Skip("no FreeAccess/Operate numeric writable attribute on dev Galaxy"); - - var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) }; - var results = await _h.RunOnAvailableAsync( - (driver, ct) => ((IWritable)driver).WriteAsync(request, ct), - CancellationToken.None); - - var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode; - var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode; - AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName); - } - - [Fact] - public async Task Configure_class_write_routes_through_secured_path_on_both_backends() - { - _h.RequireBoth(); - - var b = new RecordingAddressSpaceBuilder(); - await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); - - var target = b.Variables.FirstOrDefault(v => - v.AttributeInfo.SecurityClass is SecurityClassification.Configure or SecurityClassification.Tune); - if (target is null) Assert.Skip("no Configure/Tune attribute on dev Galaxy"); - - var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) }; - var results = await _h.RunOnAvailableAsync( - (driver, ct) => ((IWritable)driver).WriteAsync(request, ct), - CancellationToken.None); - - // Both backends route through the secured-write path. The exact StatusCode - // depends on whether the running test identity has write permission on the - // dev Galaxy — what matters here is that they agree on the status *class* - // (Good vs Bad vs Uncertain), not which exact code they produce. - var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode; - var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode; - AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName); - } - - /// - /// Pin the parity invariant that *matters*: both backends classify the same - /// write outcome as Good / Uncertain / Bad. The exact OPC UA code can diverge - /// because legacy MxAccessGalaxyBackend flat-maps every failure to - /// BadInternalError while the new GatewayGalaxyDataWriter uses - /// MxStatusProxy.RawDetectedBy to distinguish gateway-layer faults - /// (BadCommunicationError) from MxAccess HRESULT faults — see - /// docs/v2/Galaxy.ParityMatrix.md "Accepted deltas". Tighter mapping - /// parity isn't worth investing in: legacy retires in PR 7.2. - /// - private static void AssertStatusClassMatches(uint legacyCode, uint mxgwCode, string tag) - { - IsBadStatus(legacyCode).ShouldBe(IsBadStatus(mxgwCode), - $"status-class (Bad) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}"); - IsGoodStatus(legacyCode).ShouldBe(IsGoodStatus(mxgwCode), - $"status-class (Good) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}"); - } - - private static bool IsBadStatus(uint code) => (code & 0xC0000000u) == 0x80000000u; - private static bool IsGoodStatus(uint code) => (code & 0xC0000000u) == 0x00000000u; -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj deleted file mode 100644 index 2ccc171..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - net10.0 - enable - enable - false - true - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs deleted file mode 100644 index 85a094d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/AggregateColumnMappingTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -[Trait("Category", "Unit")] -public sealed class AggregateColumnMappingTests -{ - [Theory] - [InlineData(HistoryAggregateType.Average, "Average")] - [InlineData(HistoryAggregateType.Minimum, "Minimum")] - [InlineData(HistoryAggregateType.Maximum, "Maximum")] - [InlineData(HistoryAggregateType.Count, "ValueCount")] - public void Maps_OpcUa_enum_to_AnalogSummary_column(HistoryAggregateType aggregate, string expected) - { - GalaxyProxyDriver.MapAggregateToColumn(aggregate).ShouldBe(expected); - } - - [Fact] - public void Total_is_not_supported() - { - Should.Throw( - () => GalaxyProxyDriver.MapAggregateToColumn(HistoryAggregateType.Total)); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs deleted file mode 100644 index 5579ef2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -[Trait("Category", "Unit")] -public sealed class BackoffTests -{ - [Fact] - public void Default_sequence_is_5_15_60_seconds_capped() - { - var b = new Backoff(); - b.Next().ShouldBe(TimeSpan.FromSeconds(5)); - b.Next().ShouldBe(TimeSpan.FromSeconds(15)); - b.Next().ShouldBe(TimeSpan.FromSeconds(60)); - b.Next().ShouldBe(TimeSpan.FromSeconds(60), "capped once past the last entry"); - } - - [Fact] - public void RecordStableRun_resets_to_the_first_delay() - { - var b = new Backoff(); - b.Next(); b.Next(); - b.RecordStableRun(); - b.Next().ShouldBe(TimeSpan.FromSeconds(5)); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs deleted file mode 100644 index 5493862..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -[Trait("Category", "Unit")] -public sealed class CircuitBreakerTests -{ - [Fact] - public void First_three_crashes_within_window_allow_respawn() - { - var breaker = new CircuitBreaker(); - var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); - - breaker.TryRecordCrash(t0, out _).ShouldBeTrue(); - breaker.TryRecordCrash(t0.AddSeconds(30), out _).ShouldBeTrue(); - breaker.TryRecordCrash(t0.AddSeconds(60), out _).ShouldBeTrue(); - } - - [Fact] - public void Fourth_crash_within_window_opens_breaker_with_sticky_alert() - { - var breaker = new CircuitBreaker(); - var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); - - for (var i = 0; i < 3; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); - - breaker.TryRecordCrash(t0.AddSeconds(120), out var remaining).ShouldBeFalse(); - remaining.ShouldBe(TimeSpan.FromHours(1)); - breaker.StickyAlertActive.ShouldBeTrue(); - } - - [Fact] - public void Cooldown_escalates_1h_then_4h_then_manual() - { - var breaker = new CircuitBreaker(); - var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); - - // Open once. - for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); - - // Cooldown starts when the breaker opens (the 4th crash, at t0+90s). Jump past 1h from there. - var openedAt = t0.AddSeconds(90); - var afterFirstCooldown = openedAt.AddHours(1).AddMinutes(1); - breaker.TryRecordCrash(afterFirstCooldown, out _).ShouldBeTrue("cooldown elapsed, breaker closes for a try"); - - // Second trip: within 5 min, breaker opens again with 4h cooldown. The crash that trips - // it is the 3rd retry since the cooldown closed (afterFirstCooldown itself counted as 1). - breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(30), out _).ShouldBeTrue(); - breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(60), out _).ShouldBeTrue(); - breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(90), out var cd2).ShouldBeFalse( - "4th crash within window reopens the breaker"); - cd2.ShouldBe(TimeSpan.FromHours(4)); - - // Third trip: 4h elapsed, breaker closes for a try, then reopens with MaxValue (manual only). - var reopenedAt = afterFirstCooldown.AddSeconds(90); - var afterSecondCooldown = reopenedAt.AddHours(4).AddMinutes(1); - breaker.TryRecordCrash(afterSecondCooldown, out _).ShouldBeTrue(); - breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(30), out _).ShouldBeTrue(); - breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(60), out _).ShouldBeTrue(); - breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(90), out var cd3).ShouldBeFalse(); - cd3.ShouldBe(TimeSpan.MaxValue); - } - - [Fact] - public void ManualReset_clears_sticky_alert_and_crash_history() - { - var breaker = new CircuitBreaker(); - var t0 = DateTime.UtcNow; - for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); - - breaker.ManualReset(); - breaker.StickyAlertActive.ShouldBeFalse(); - - breaker.TryRecordCrash(t0.AddMinutes(10), out _).ShouldBeTrue(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyHistorianWriterMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyHistorianWriterMappingTests.cs deleted file mode 100644 index 0154bbe..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyHistorianWriterMappingTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -/// -/// Phase 7 follow-up #247 — covers the wire-format translation between the -/// the SQLite sink hands to the writer + the -/// the Galaxy.Host IPC contract expects, plus -/// the per-event outcome enum mapping. Pure functions; the round-trip over a real -/// pipe is exercised by the live Host suite (task #240). -/// -[Trait("Category", "Unit")] -public sealed class GalaxyHistorianWriterMappingTests -{ - [Fact] - public void ToDto_round_trips_every_field() - { - var ts = new DateTime(2026, 4, 20, 14, 30, 0, DateTimeKind.Utc); - var e = new AlarmHistorianEvent( - AlarmId: "al-7", - EquipmentPath: "/Site/Line/Cell", - AlarmName: "HighTemp", - AlarmTypeName: "LimitAlarm", - Severity: AlarmSeverity.High, - EventKind: "RaiseEvent", - Message: "Temp 92°C exceeded 90°C", - User: "operator-7", - Comment: "ack with reason", - TimestampUtc: ts); - - var dto = GalaxyHistorianWriter.ToDto(e); - - dto.AlarmId.ShouldBe("al-7"); - dto.EquipmentPath.ShouldBe("/Site/Line/Cell"); - dto.AlarmName.ShouldBe("HighTemp"); - dto.AlarmTypeName.ShouldBe("LimitAlarm"); - dto.Severity.ShouldBe((int)AlarmSeverity.High); - dto.EventKind.ShouldBe("RaiseEvent"); - dto.Message.ShouldBe("Temp 92°C exceeded 90°C"); - dto.User.ShouldBe("operator-7"); - dto.Comment.ShouldBe("ack with reason"); - dto.TimestampUtcUnixMs.ShouldBe(new DateTimeOffset(ts, TimeSpan.Zero).ToUnixTimeMilliseconds()); - } - - [Fact] - public void ToDto_preserves_null_Comment() - { - var e = new AlarmHistorianEvent( - "a", "/p", "n", "AlarmCondition", AlarmSeverity.Low, "RaiseEvent", "m", - User: "system", Comment: null, TimestampUtc: DateTime.UtcNow); - - GalaxyHistorianWriter.ToDto(e).Comment.ShouldBeNull(); - } - - [Theory] - [InlineData(HistorianAlarmEventOutcomeDto.Ack, HistorianWriteOutcome.Ack)] - [InlineData(HistorianAlarmEventOutcomeDto.RetryPlease, HistorianWriteOutcome.RetryPlease)] - [InlineData(HistorianAlarmEventOutcomeDto.PermanentFail, HistorianWriteOutcome.PermanentFail)] - public void MapOutcome_round_trips_every_byte( - HistorianAlarmEventOutcomeDto wire, HistorianWriteOutcome expected) - { - GalaxyHistorianWriter.MapOutcome(wire).ShouldBe(expected); - } - - [Fact] - public void MapOutcome_unknown_byte_throws() - { - Should.Throw( - () => GalaxyHistorianWriter.MapOutcome((HistorianAlarmEventOutcomeDto)0xFF)); - } - - [Fact] - public void Null_client_rejected() - { - Should.Throw(() => new GalaxyHistorianWriter(null!)); - } - -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyIpcClientRoutingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyIpcClientRoutingTests.cs deleted file mode 100644 index c6235b3..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/GalaxyIpcClientRoutingTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.IO.Pipes; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -/// -/// Exercises the single-pending-slot router in : request/response -/// matching, handling, and routing of unsolicited push -/// frames (e.g. ) arriving between a request and -/// its response. Without the router, a push event interleaved with a call would be consumed -/// as the response and the next would -/// fail with an "Expected X, got Y" mismatch — the bug that blocked task #112's live Galaxy -/// E2E on the dev box. -/// -[Trait("Category", "Unit")] -public sealed class GalaxyIpcClientRoutingTests -{ - private const string Secret = "routing-suite-secret"; - - [Fact] - public async Task Response_matching_expected_kind_completes_the_call() - { - var (pipe, serverStream, clientTask) = await StartPairAsync(); - - using (serverStream) - await using (var client = await clientTask) - { - using var reader = new FrameReader(serverStream, leaveOpen: true); - using var writer = new FrameWriter(serverStream, leaveOpen: true); - - var callTask = client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse, - CancellationToken.None); - - var request = await reader.ReadFrameAsync(CancellationToken.None); - request!.Value.Kind.ShouldBe(MessageKind.OpenSessionRequest); - - await writer.WriteAsync(MessageKind.OpenSessionResponse, - new OpenSessionResponse { Success = true, SessionId = 42 }, - CancellationToken.None); - - var response = await callTask.WaitAsync(TimeSpan.FromSeconds(2)); - response.Success.ShouldBeTrue(); - response.SessionId.ShouldBe(42); - } - } - - [Fact] - public async Task ErrorResponse_throws_GalaxyIpcException_regardless_of_expected_kind() - { - var (pipe, serverStream, clientTask) = await StartPairAsync(); - - using (serverStream) - await using (var client = await clientTask) - { - using var reader = new FrameReader(serverStream, leaveOpen: true); - using var writer = new FrameWriter(serverStream, leaveOpen: true); - - var callTask = client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse, - CancellationToken.None); - - await reader.ReadFrameAsync(CancellationToken.None); - await writer.WriteAsync(MessageKind.ErrorResponse, - new ErrorResponse { Code = "bad-request", Message = "malformed" }, - CancellationToken.None); - - var ex = await Should.ThrowAsync(() => callTask.WaitAsync(TimeSpan.FromSeconds(2))); - ex.Code.ShouldBe("bad-request"); - ex.Message.ShouldContain("malformed"); - } - } - - [Fact] - public async Task Unsolicited_event_between_request_and_response_routes_to_handler_not_the_call() - { - var (pipe, serverStream, clientTask) = await StartPairAsync(); - - using (serverStream) - await using (var client = await clientTask) - { - var eventFrames = new List<(MessageKind Kind, byte[] Body)>(); - var eventReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - client.SetEventHandler((k, body) => - { - eventFrames.Add((k, body)); - if (k == MessageKind.RuntimeStatusChange) eventReceived.TrySetResult(true); - return Task.CompletedTask; - }); - - using var reader = new FrameReader(serverStream, leaveOpen: true); - using var writer = new FrameWriter(serverStream, leaveOpen: true); - - var callTask = client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse, - CancellationToken.None); - - await reader.ReadFrameAsync(CancellationToken.None); - - // Push event lands first — the bug this test guards against is CallAsync consuming - // this frame as the response and failing with "Expected X, got Y". - await writer.WriteAsync(MessageKind.RuntimeStatusChange, - new RuntimeStatusChangeNotification - { - Status = new HostConnectivityStatus - { - HostName = "host-a", RuntimeStatus = "Running", LastObservedUtcUnixMs = 1, - }, - }, CancellationToken.None); - - await writer.WriteAsync(MessageKind.OpenSessionResponse, - new OpenSessionResponse { Success = true, SessionId = 7 }, - CancellationToken.None); - - var response = await callTask.WaitAsync(TimeSpan.FromSeconds(2)); - response.SessionId.ShouldBe(7); - - await eventReceived.Task.WaitAsync(TimeSpan.FromSeconds(2)); - var runtime = eventFrames.ShouldHaveSingleItem(); - runtime.Kind.ShouldBe(MessageKind.RuntimeStatusChange); - var decoded = MessagePackSerializer.Deserialize(runtime.Body); - decoded.Status.HostName.ShouldBe("host-a"); - } - } - - [Fact] - public async Task Idle_push_event_with_no_pending_call_still_reaches_handler() - { - var (pipe, serverStream, clientTask) = await StartPairAsync(); - - using (serverStream) - await using (var client = await clientTask) - { - var received = new TaskCompletionSource<(MessageKind, byte[])>(TaskCreationOptions.RunContinuationsAsynchronously); - client.SetEventHandler((k, body) => { received.TrySetResult((k, body)); return Task.CompletedTask; }); - - using var writer = new FrameWriter(serverStream, leaveOpen: true); - await writer.WriteAsync(MessageKind.HostConnectivityStatus, - new HostConnectivityStatus { HostName = "h", RuntimeStatus = "Running", LastObservedUtcUnixMs = 1 }, - CancellationToken.None); - - var (kind, _) = await received.Task.WaitAsync(TimeSpan.FromSeconds(2)); - kind.ShouldBe(MessageKind.HostConnectivityStatus); - } - } - - [Fact] - public async Task Peer_closing_pipe_during_pending_call_surfaces_as_EndOfStream() - { - var (pipe, serverStream, clientTask) = await StartPairAsync(); - - await using var client = await clientTask; - - using var reader = new FrameReader(serverStream, leaveOpen: true); - - var callTask = client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse, - CancellationToken.None); - - await reader.ReadFrameAsync(CancellationToken.None); - serverStream.Dispose(); - - await Should.ThrowAsync(() => callTask.WaitAsync(TimeSpan.FromSeconds(2))); - } - - // ---- test harness ---------------------------------------------------- - - private static async Task<(string PipeName, NamedPipeServerStream Server, Task Client)> StartPairAsync() - { - var pipeName = $"GalaxyIpcRouting-{Guid.NewGuid():N}"; - var serverStream = new NamedPipeServerStream( - pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - - // Drive a Hello/HelloAck handshake on a background task so the client's ConnectAsync - // can complete. After the handshake the test owns the stream for manual framing. - var acceptTask = Task.Run(async () => - { - await serverStream.WaitForConnectionAsync(); - using var reader = new FrameReader(serverStream, leaveOpen: true); - using var writer = new FrameWriter(serverStream, leaveOpen: true); - - var hello = await reader.ReadFrameAsync(CancellationToken.None); - if (hello is null || hello.Value.Kind != MessageKind.Hello) - throw new InvalidOperationException("expected Hello first"); - - await writer.WriteAsync(MessageKind.HelloAck, - new HelloAck { Accepted = true, HostName = "test-host" }, - CancellationToken.None); - }); - - var clientTask = GalaxyIpcClient.ConnectAsync(pipeName, Secret, TimeSpan.FromSeconds(5), CancellationToken.None); - await acceptTask; - return (pipeName, serverStream, clientTask); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs deleted file mode 100644 index a05f0f0..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -[Trait("Category", "Unit")] -public sealed class HeartbeatMonitorTests -{ - [Fact] - public void Single_miss_does_not_declare_dead() - { - var m = new HeartbeatMonitor(); - m.RecordMiss().ShouldBeFalse(); - m.RecordMiss().ShouldBeFalse(); - } - - [Fact] - public void Three_consecutive_misses_declare_host_dead() - { - var m = new HeartbeatMonitor(); - m.RecordMiss().ShouldBeFalse(); - m.RecordMiss().ShouldBeFalse(); - m.RecordMiss().ShouldBeTrue(); - } - - [Fact] - public void Ack_resets_the_miss_counter() - { - var m = new HeartbeatMonitor(); - m.RecordMiss(); - m.RecordMiss(); - - m.RecordAck(DateTime.UtcNow); - - m.ConsecutiveMisses.ShouldBe(0); - m.RecordMiss().ShouldBeFalse(); - m.RecordMiss().ShouldBeFalse(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs deleted file mode 100644 index 9986c62..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HistoricalEventMappingTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -/// -/// Pins — the wire-to-domain mapping -/// from (MessagePack-annotated IPC contract, -/// Unix-ms timestamps) to Core.Abstractions.HistoricalEvent (domain record, -/// timestamps). Added in PR 35 alongside the new -/// IHistoryProvider.ReadEventsAsync method. -/// -[Trait("Category", "Unit")] -public sealed class HistoricalEventMappingTests -{ - [Fact] - public void Maps_every_field_from_wire_to_domain_record() - { - var wire = new GalaxyHistoricalEvent - { - EventId = "evt-42", - SourceName = "Tank1.HiAlarm", - EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z - ReceivedTimeUtcUnixMs = 1_700_000_000_500L, - DisplayText = "High level reached", - Severity = 750, - }; - - var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); - - domain.EventId.ShouldBe("evt-42"); - domain.SourceName.ShouldBe("Tank1.HiAlarm"); - domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc)); - domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc)); - domain.Message.ShouldBe("High level reached"); - domain.Severity.ShouldBe((ushort)750); - } - - [Fact] - public void Preserves_null_SourceName_and_DisplayText() - { - // Historical rows from the Galaxy event historian often omit source or message for - // system events (e.g. time sync). The mapping must preserve null — callers use it to - // distinguish system events from alarm events. - var wire = new GalaxyHistoricalEvent - { - EventId = "sys-1", - SourceName = null, - EventTimeUtcUnixMs = 0, - ReceivedTimeUtcUnixMs = 0, - DisplayText = null, - Severity = 1, - }; - - var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); - - domain.SourceName.ShouldBeNull(); - domain.Message.ShouldBeNull(); - } - - [Fact] - public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc() - { - // Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the - // resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply - // an unexpected local-time offset. - var wire = new GalaxyHistoricalEvent - { - EventId = "e", - EventTimeUtcUnixMs = 1_000L, - ReceivedTimeUtcUnixMs = 2_000L, - }; - - var domain = GalaxyProxyDriver.ToHistoricalEvent(wire); - - domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); - domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HostSubprocessParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HostSubprocessParityTests.cs deleted file mode 100644 index f45add4..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HostSubprocessParityTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using System.Security.Principal; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; - -/// -/// The honest cross-FX parity test — spawns the actual OtOpcUa.Driver.Galaxy.Host.exe -/// subprocess (net48 x86), the Proxy connects via real named pipe, exercises Discover -/// against the live Galaxy ZB DB, and asserts gobjects come back. This is the production -/// deployment shape (Tier C: separate process, IPC over named pipe, Proxy in the .NET 10 -/// server process). Skipped when the Host EXE isn't built or Galaxy is unreachable. -/// -[Trait("Category", "ProcessSpawnParity")] -public sealed class HostSubprocessParityTests : IDisposable -{ - private Process? _hostProcess; - - public void Dispose() - { - if (_hostProcess is not null && !_hostProcess.HasExited) - { - try { _hostProcess.Kill(entireProcessTree: true); } catch { /* ignore */ } - try { _hostProcess.WaitForExit(5_000); } catch { /* ignore */ } - } - _hostProcess?.Dispose(); - } - - private static string? FindHostExe() - { - // The test assembly lives at tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/bin/Debug/net10.0/. - // The Host EXE lives at src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/bin/Debug/net48/. - var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - var solutionRoot = asmDir; - for (var i = 0; i < 8 && solutionRoot is not null; i++) - { - if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) - break; - solutionRoot = Path.GetDirectoryName(solutionRoot); - } - if (solutionRoot is null) return null; - - var candidate = Path.Combine(solutionRoot, - "src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48", - "OtOpcUa.Driver.Galaxy.Host.exe"); - return File.Exists(candidate) ? candidate : null; - } - - private static async Task ZbReachableAsync() - { - try - { - using var client = new System.Net.Sockets.TcpClient(); - var task = client.ConnectAsync("localhost", 1433); - return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; - } - catch { return false; } - } - - [Fact] - public async Task Spawned_Host_in_db_mode_lets_Proxy_Discover_real_Galaxy_gobjects() - { - if (!OperatingSystem.IsWindows()) return; - if (!await ZbReachableAsync()) return; - - var hostExe = FindHostExe(); - if (hostExe is null) return; // skip when the Host hasn't been built - - using var identity = WindowsIdentity.GetCurrent(); - var sid = identity.User!; - var pipeName = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}"; - const string secret = "parity-secret"; - - var psi = new ProcessStartInfo(hostExe) - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - EnvironmentVariables = - { - ["OTOPCUA_GALAXY_PIPE"] = pipeName, - ["OTOPCUA_ALLOWED_SID"] = sid.Value, - ["OTOPCUA_GALAXY_SECRET"] = secret, - ["OTOPCUA_GALAXY_BACKEND"] = "db", // SQL-only — doesn't need MXAccess - ["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", - }, - }; - - _hostProcess = Process.Start(psi) - ?? throw new InvalidOperationException("Failed to spawn Galaxy.Host"); - - // Wait for the pipe to come up — the Host's PipeServer takes ~100ms to bind. - await Task.Delay(2_000); - - await using var client = await GalaxyIpcClient.ConnectAsync( - pipeName, secret, TimeSpan.FromSeconds(5), CancellationToken.None); - - var sessionResp = await client.CallAsync( - MessageKind.OpenSessionRequest, - new OpenSessionRequest { DriverInstanceId = "parity", DriverConfigJson = "{}" }, - MessageKind.OpenSessionResponse, - CancellationToken.None); - sessionResp.Success.ShouldBeTrue(sessionResp.Error); - - var discoverResp = await client.CallAsync( - MessageKind.DiscoverHierarchyRequest, - new DiscoverHierarchyRequest { SessionId = sessionResp.SessionId }, - MessageKind.DiscoverHierarchyResponse, - CancellationToken.None); - - discoverResp.Success.ShouldBeTrue(discoverResp.Error); - discoverResp.Objects.Length.ShouldBeGreaterThan(0, - "live Galaxy ZB has at least one deployed gobject"); - - await client.SendOneWayAsync(MessageKind.CloseSessionRequest, - new CloseSessionRequest { SessionId = sessionResp.SessionId }, CancellationToken.None); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs deleted file mode 100644 index 9941aaf..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackConfig.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using Microsoft.Win32; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; - -/// -/// Resolves the pipe name + shared secret the live needs -/// to connect to a running OtOpcUaGalaxyHost Windows service. Two sources are -/// consulted, first match wins: -/// -/// Explicit env vars (OTOPCUA_GALAXY_PIPE, OTOPCUA_GALAXY_SECRET) — lets CI / benchwork override. -/// The service's per-process Environment registry values under -/// HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost — what -/// Install-Services.ps1 writes at install time. Requires the test to run as a -/// principal with read access to that registry key (typically Administrators). -/// -/// -/// -/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the -/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret -/// in tests would diverge from production the moment someone re-installed the service. -/// -public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source) -{ - public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE"; - public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET"; - public const string ServiceRegistryKey = - @"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost"; - public const string DefaultPipeName = "OtOpcUaGalaxy"; - - public static LiveStackConfig? Resolve() - { - var envPipe = Environment.GetEnvironmentVariable(EnvPipeName); - var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret); - if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret)) - return new LiveStackConfig(envPipe, envSecret, "env vars"); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return null; - - return FromServiceRegistry(); - } - - [SupportedOSPlatform("windows")] - private static LiveStackConfig? FromServiceRegistry() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey); - if (key is null) return null; - var env = key.GetValue("Environment") as string[]; - if (env is null || env.Length == 0) return null; - - string? pipe = null, secret = null; - foreach (var line in env) - { - var eq = line.IndexOf('='); - if (eq <= 0) continue; - var name = line[..eq]; - var value = line[(eq + 1)..]; - if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value; - else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value; - } - - if (string.IsNullOrWhiteSpace(secret)) return null; - return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry"); - } - catch - { - // Access denied / key missing / malformed — caller gets null and surfaces a Skip. - return null; - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs deleted file mode 100644 index 0565c8e..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; - -/// -/// Connects a single to the already-running -/// OtOpcUaGalaxyHost Windows service for the lifetime of a test class. Uses -/// to decide whether to proceed; on failure, -/// is populated and each test calls -/// to translate that into Assert.Skip. -/// -/// -/// -/// Does NOT spawn the Host process. Production deploys OtOpcUaGalaxyHost -/// as a standalone Windows service — spawning a second instance from a test would -/// bypass the COM-apartment + service-account setup and fail differently than -/// production (see project_galaxy_host_service.md memory). -/// -/// -/// Shared-secret handling: read from — env vars -/// first, then the service's registry-stored Environment values. Requires -/// the test process to have read access to -/// HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost; on a dev box -/// that typically means running the test host elevated, or exporting -/// OTOPCUA_GALAXY_SECRET out-of-band. -/// -/// -public sealed class LiveStackFixture : IAsyncLifetime -{ - public GalaxyProxyDriver? Driver { get; private set; } - - public string? SkipReason { get; private set; } - - public PrerequisiteReport? PrerequisiteReport { get; private set; } - - public LiveStackConfig? Config { get; private set; } - - public async ValueTask InitializeAsync() - { - // 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing. - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync( - new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false }, - cts.Token); - - if (!PrerequisiteReport.IsLivetestReady) - { - SkipReason = PrerequisiteReport.SkipReason; - return; - } - - // 2. Secret / pipe-name resolution. If the service is running but we can't discover its - // env vars from registry (non-elevated test host), a clear message beats a silent - // connect-rejected failure 10 seconds later. - Config = LiveStackConfig.Resolve(); - if (Config is null) - { - SkipReason = - $"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " + - $"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " + - $"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment."; - return; - } - - // 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second - // ConnectTimeout gives enough headroom for a service that just started. - Driver = new GalaxyProxyDriver(new GalaxyProxyOptions - { - DriverInstanceId = "live-stack-smoke", - PipeName = Config.PipeName, - SharedSecret = Config.SharedSecret, - ConnectTimeout = TimeSpan.FromSeconds(5), - }); - - try - { - await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); - } - catch (Exception ex) - { - SkipReason = - $"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " + - $"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " + - $"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " + - $"test must run as that user), or Host's backend couldn't connect to ZB."; - Driver.Dispose(); - Driver = null; - return; - } - } - - public async ValueTask DisposeAsync() - { - if (Driver is not null) - { - try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ } - Driver.Dispose(); - } - } - - /// - /// Translate into Assert.Skip. Tests call this at the - /// top of every fact so a fixture init failure shows up as a cleanly-skipped test with - /// the full prerequisites report, not a cascading NullReferenceException on - /// . - /// - public void SkipIfUnavailable() - { - if (SkipReason is not null) Assert.Skip(SkipReason); - } - -} - -[CollectionDefinition(Name)] -public sealed class LiveStackCollection : ICollectionFixture -{ - public const string Name = "LiveStack"; -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs deleted file mode 100644 index b2381b2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack; - -/// -/// End-to-end smoke against the installed OtOpcUaGalaxyHost Windows service. -/// Closes LMX follow-up #5 — exercises the full topology: -/// in-process → named-pipe IPC → OtOpcUaGalaxyHost service → MxAccessGalaxyBackend → -/// live MXAccess runtime → real Galaxy objects + attributes. -/// -/// -/// -/// Preconditions (all checked by , surfaced via -/// Assert.Skip when missing): -/// -/// -/// AVEVA System Platform installed + Platform deployed. -/// aaBootstrap / aaGR / NmxSvc / MSSQLSERVER running. -/// MXAccess COM server registered. -/// ZB database exists with at least one deployed gobject. -/// OtOpcUaGalaxyHost service installed + running (named pipe accepting connections). -/// Shared secret discoverable via OTOPCUA_GALAXY_SECRET env var or the -/// service's registry Environment values (test host typically needs to be elevated -/// to read the latter). -/// Test process runs as the account listed in the service's pipe ACL -/// (OTOPCUA_ALLOWED_SID, typically the service account per decision #76). -/// -/// -/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a -/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't -/// accidentally mutate a process-critical value. Adding a write test is a follow-up -/// PR that reuses this fixture. -/// -/// -[Trait("Category", "LiveGalaxy")] -[Collection(LiveStackCollection.Name)] -public sealed class LiveStackSmokeTests(LiveStackFixture fixture) -{ - [Fact] - public void Fixture_initialized_successfully() - { - fixture.SkipIfUnavailable(); - // If the fixture init succeeded, Driver is non-null and InitializeAsync completed. - // This is the cheapest possible assertion that the IPC handshake worked end-to-end; - // every other test in this class depends on it. - fixture.Driver.ShouldNotBeNull(); - fixture.Config.ShouldNotBeNull(); - fixture.PrerequisiteReport.ShouldNotBeNull(); - fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason); - } - - [Fact] - public void Driver_reports_Healthy_after_IPC_handshake() - { - fixture.SkipIfUnavailable(); - var health = fixture.Driver!.GetHealth(); - health.State.ShouldBe(DriverState.Healthy, - $"Expected Healthy after successful IPC connect; Reason={health.LastError}"); - } - - [Fact] - public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy() - { - fixture.SkipIfUnavailable(); - var builder = new CapturingAddressSpaceBuilder(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await fixture.Driver!.DiscoverAsync(builder, cts.Token); - - builder.Variables.Count.ShouldBeGreaterThan(0, - "Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " + - "Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment)."); - - // Every discovered attribute must carry a non-empty FullName so the OPC UA server can - // route reads/writes back. Regression guard — PR 19 normalized this across drivers. - builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName)); - } - - [Fact] - public void GetHostStatuses_reports_at_least_one_platform() - { - fixture.SkipIfUnavailable(); - var statuses = fixture.Driver!.GetHostStatuses(); - statuses.Count.ShouldBeGreaterThan(0, - "Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " + - "Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally."); - - // Host names are driver-opaque to the Core but non-empty by contract. - statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName)); - } - - [Fact] - public async Task Can_read_a_discovered_variable_from_live_galaxy() - { - fixture.SkipIfUnavailable(); - var builder = new CapturingAddressSpaceBuilder(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await fixture.Driver!.DiscoverAsync(builder, cts.Token); - builder.Variables.Count.ShouldBeGreaterThan(0); - - // Pick the first discovered variable. Read-only smoke — we don't assert on Value, - // only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back - // returns a snapshot with a non-BadInternalError status. Galaxy attributes default to - // Uncertain quality until the Engine's first scan publishes them, which is fine here. - var full = builder.Variables[0].AttributeInfo.FullName; - var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token); - - snapshots.Count.ShouldBe(1); - var snap = snapshots[0]; - snap.StatusCode.ShouldNotBe(0x80020000u, - $"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " + - $"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs."); - } - - [Fact] - public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001() - { - // PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable - // Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed - // with for exactly this kind of integration testing. We invert the current value and - // assert the new value comes back, then restore the original so the test is effectively - // idempotent (Galaxy holds the value across runs since it's a deployed UDA). - fixture.SkipIfUnavailable(); - const string fullRef = "DelmiaReceiver_001.TestAttribute"; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - - // Read current value first — gives the cleanup path the right baseline. Galaxy may - // return Uncertain quality until the Engine has scanned the attribute at least once; - // we don't read into a strongly-typed bool until Status is Good. - var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0]; - before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}"); - var originalBool = Convert.ToBoolean(before.Value ?? false); - var inverted = !originalBool; - - try - { - // Write the inverted value via IWritable. - var writeResults = await fixture.Driver!.WriteAsync( - [new(fullRef, inverted)], cts.Token); - writeResults.Count.ShouldBe(1); - writeResults[0].StatusCode.ShouldBe(0u, - $"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " + - $"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\."); - - // The Engine's scan + acknowledgement is async — read in a short loop with a 5s - // budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but - // we give headroom for first-scan after a service restart. - DataValueSnapshot after = default!; - var deadline = DateTime.UtcNow.AddSeconds(5); - while (DateTime.UtcNow < deadline) - { - after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0]; - if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break; - await Task.Delay(200, cts.Token); - } - after.StatusCode.ShouldBe(0u, "post-write read failed"); - Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted, - $"Wrote {inverted} but Galaxy returned {after.Value} after the scan window."); - } - finally - { - // Restore — best-effort. If this throws the test still reports its primary result; - // we just leave a flipped TestAttribute on the dev box (benign, name says it all). - try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } - catch { /* swallow */ } - } - } - - [Fact] - public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write() - { - // Subscribe + write is the canonical "is the data path actually live" test for - // an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial- - // value callback within a couple of seconds (per ISubscribable's contract — the - // driver MAY fire OnDataChange immediately with the current value), then write a - // distinct value and expect a second callback carrying the new value. - fixture.SkipIfUnavailable(); - const string fullRef = "DelmiaReceiver_001.TestAttribute"; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - - // Capture every OnDataChange notification for this fullRef onto a thread-safe queue - // we can poll from the test thread. Galaxy's MXAccess advisory fires on its own - // thread; we don't want to block it. - var notifications = new System.Collections.Concurrent.ConcurrentQueue(); - void Handler(object? sender, DataChangeEventArgs e) - { - if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase)) - notifications.Enqueue(e.Snapshot); - } - fixture.Driver!.OnDataChange += Handler; - - // Read current value so we know which value to write to force a transition. - var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0]; - var originalBool = Convert.ToBoolean(before.Value ?? false); - var toWrite = !originalBool; - - ISubscriptionHandle? handle = null; - try - { - handle = await fixture.Driver!.SubscribeAsync( - [fullRef], TimeSpan.FromMilliseconds(250), cts.Token); - - // Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s. - await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token); - notifications.Count.ShouldBeGreaterThanOrEqualTo(1, - $"No initial-value OnDataChange for {fullRef} within 5s. " + - $"Either MXAccess subscription failed silently or the Engine hasn't scanned yet."); - - // Drain the initial-value queue before writing so we count post-write deltas only. - var initialCount = notifications.Count; - - // Write the toggled value. Engine scan + advisory fires the second callback. - var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token); - w[0].StatusCode.ShouldBe(0u); - - await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token); - notifications.Count.ShouldBeGreaterThan(initialCount, - $"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s."); - - // Find the post-write notification carrying the toggled value (initial value may - // appear multiple times before the write commits — search the tail). - var postWrite = notifications.ToArray().Reverse() - .FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite); - postWrite.ShouldNotBe(default, - $"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " + - string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}"))); - } - finally - { - fixture.Driver!.OnDataChange -= Handler; - if (handle is not null) - { - try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ } - } - // Restore baseline. - try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ } - } - } - - private static async Task WaitForAsync(Func predicate, TimeSpan budget, CancellationToken ct) - { - var deadline = DateTime.UtcNow + budget; - while (DateTime.UtcNow < deadline) - { - if (predicate()) return; - await Task.Delay(100, ct); - } - } - - /// - /// Minimal implementation that captures every - /// Variable() call into a flat list so tests can inspect what discovery produced - /// without running the full OPC UA node-manager stack. - /// - private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder - { - public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = []; - - public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - Variables.Add((browseName, attributeInfo)); - return new NoopHandle(attributeInfo.FullName); - } - public void AddProperty(string browseName, DriverDataType dataType, object? value) { } - - private sealed class NoopHandle(string fullReference) : IVariableHandle - { - public string FullReference { get; } = fullReference; - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink(); - private sealed class NoopSink : IAlarmConditionSink - { - public void OnTransition(AlarmEventArgs args) { } - } - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj deleted file mode 100644 index 25fac36..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - net10.0 - enable - enable - false - true - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs deleted file mode 100644 index 2992650..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Reflection; -using MessagePack; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests; - -[Trait("Category", "Unit")] -public sealed class ContractRoundTripTests -{ - /// - /// Every MessagePack contract in the Shared project must round-trip. Byte-for-byte equality - /// on re-serialization proves the contract is deterministic — critical for the Hello - /// version-negotiation hash and for debugging wire dumps. - /// - [Fact] - public void All_MessagePackObject_contracts_round_trip_byte_for_byte() - { - var contractTypes = typeof(Hello).Assembly.GetTypes() - .Where(t => t.GetCustomAttribute() is not null) - .ToList(); - - contractTypes.Count.ShouldBeGreaterThan(15, "scan should find all contracts"); - - foreach (var type in contractTypes) - { - var instance = Activator.CreateInstance(type); - var bytes1 = MessagePackSerializer.Serialize(type, instance); - var hydrated = MessagePackSerializer.Deserialize(type, bytes1); - var bytes2 = MessagePackSerializer.Serialize(type, hydrated); - - bytes2.ShouldBe(bytes1, $"{type.Name} did not round-trip byte-for-byte"); - } - } - - [Fact] - public void Hello_default_reports_current_protocol_version() - { - var h = new Hello { PeerName = "Proxy", SharedSecret = "x" }; - h.ProtocolMajor.ShouldBe(Hello.CurrentMajor); - h.ProtocolMinor.ShouldBe(Hello.CurrentMinor); - } - - [Fact] - public void OpenSessionRequest_round_trips_values() - { - var req = new OpenSessionRequest { DriverInstanceId = "gal-1", DriverConfigJson = "{\"x\":1}" }; - var bytes = MessagePackSerializer.Serialize(req); - var hydrated = MessagePackSerializer.Deserialize(bytes); - - hydrated.DriverInstanceId.ShouldBe("gal-1"); - hydrated.DriverConfigJson.ShouldBe("{\"x\":1}"); - } - - [Fact] - public void Contracts_reference_only_BCL_and_MessagePack() - { - var asm = typeof(Hello).Assembly; - var references = asm.GetReferencedAssemblies() - .Select(n => n.Name!) - .Where(n => !n.StartsWith("System.") && n != "mscorlib" && n != "netstandard") - .ToList(); - - // Only MessagePack should appear outside BCL — no System.Text.Json, no EF, no AspNetCore. - references.ShouldAllBe(n => n == "MessagePack" || n == "MessagePack.Annotations"); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs deleted file mode 100644 index 3b1c143..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests; - -[Trait("Category", "Unit")] -public sealed class FramingTests -{ - [Fact] - public async Task FrameWriter_FrameReader_round_trip_preserves_kind_and_body() - { - using var ms = new MemoryStream(); - - using (var writer = new FrameWriter(ms, leaveOpen: true)) - { - await writer.WriteAsync(MessageKind.Hello, - new Hello { PeerName = "p", SharedSecret = "s" }, TestContext.Current.CancellationToken); - await writer.WriteAsync(MessageKind.Heartbeat, - new Heartbeat { SequenceNumber = 7, UtcUnixMs = 42 }, TestContext.Current.CancellationToken); - } - - ms.Position = 0; - using var reader = new FrameReader(ms, leaveOpen: true); - - var f1 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value; - f1.Kind.ShouldBe(MessageKind.Hello); - FrameReader.Deserialize(f1.Body).PeerName.ShouldBe("p"); - - var f2 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value; - f2.Kind.ShouldBe(MessageKind.Heartbeat); - FrameReader.Deserialize(f2.Body).SequenceNumber.ShouldBe(7L); - - var eof = await reader.ReadFrameAsync(TestContext.Current.CancellationToken); - eof.ShouldBeNull(); - } - - [Fact] - public async Task FrameReader_rejects_frames_larger_than_the_cap() - { - using var ms = new MemoryStream(); - var evilLen = Framing.MaxFrameBodyBytes + 1; - ms.Write(new byte[] - { - (byte)((evilLen >> 24) & 0xFF), - (byte)((evilLen >> 16) & 0xFF), - (byte)((evilLen >> 8) & 0xFF), - (byte)( evilLen & 0xFF), - }, 0, 4); - ms.WriteByte((byte)MessageKind.Hello); - ms.Position = 0; - - using var reader = new FrameReader(ms, leaveOpen: true); - await Should.ThrowAsync(() => - reader.ReadFrameAsync(TestContext.Current.CancellationToken).AsTask()); - } - - private static class TestContext - { - public static TestContextHelper Current { get; } = new(); - } - - private sealed class TestContextHelper - { - public CancellationToken CancellationToken => CancellationToken.None; - } -} - -file static class TaskExtensions -{ - public static Task AsTask(this ValueTask vt) => vt.AsTask(); - public static Task AsTask(this Task t) => t; -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj deleted file mode 100644 index 3d644f5..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net10.0 - enable - enable - false - true - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs deleted file mode 100644 index f070367..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/AvevaPrerequisites.cs +++ /dev/null @@ -1,163 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -/// -/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a -/// whose SkipReason feeds Assert.Skip when -/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per -/// category instead of a flood of individual skips. -/// -/// -/// Call shape: -/// -/// var report = await AvevaPrerequisites.CheckAllAsync(); -/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason); -/// -/// Categories in rough order of 'would I want to know first?': -/// -/// Environment — process bitness, OS platform, RPCSS up. -/// AvevaInstall — Framework registry, install paths, no pending reboot. -/// AvevaCoreService — aaBootstrap / aaGR / NmxSvc running. -/// MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk. -/// GalaxyRepository — SQL reachable, ZB exists, deployed-object count. -/// OtOpcUaService — our two Windows services + GLAuth. -/// AvevaSoftService — aaLogger etc., warn only. -/// AvevaHistorian — aahClientAccessPoint etc., optional. -/// -/// What's NOT checked here: end-to-end subscribe / read / write against a real -/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just -/// tells them whether running is worthwhile. -/// -public static class AvevaPrerequisites -{ - // -------- Individual service lists (kept as data so tests can inspect / override) -------- - - /// Services whose absence means live-Galaxy tests can't run at all. - internal static readonly (string Name, string Purpose)[] CoreServices = - [ - ("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"), - ("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"), - ("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"), - ("MSSQLSERVER", "SQL Server instance that hosts the ZB database"), - ]; - - /// Warn-but-don't-fail AVEVA services. - internal static readonly (string Name, string Purpose)[] SoftServices = - [ - ("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"), - ("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"), - ("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"), - ]; - - /// Optional AVEVA Historian services — only required for HistoryRead IPC paths. - internal static readonly (string Name, string Purpose)[] HistorianServices = - [ - ("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"), - ("aahGateway", "AVEVA Historian Gateway"), - ]; - - /// OtOpcUa-stack Windows services + third-party deps we manage. - internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices = - [ - ("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true), - ("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false), - ("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false), - ]; - - // -------- Orchestrator -------- - - public static async Task CheckAllAsync( - Options? options = null, CancellationToken ct = default) - { - options ??= new Options(); - var checks = new List(); - - // Environment - checks.Add(MxAccessComProbe.CheckProcessBitness()); - - // AvevaInstall — registry + files - checks.Add(RegistryProbe.CheckFrameworkInstalled()); - checks.Add(RegistryProbe.CheckPlatformDeployed()); - checks.Add(RegistryProbe.CheckRebootPending()); - - // AvevaCoreService - foreach (var (name, purpose) in CoreServices) - checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose)); - - // MxAccessCom - checks.Add(MxAccessComProbe.Check()); - - // GalaxyRepository - checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct)); - // Deployed-object count only makes sense if the DB check passed. - if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass) - checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct)); - - // OtOpcUaService - foreach (var (name, purpose, hard) in OtOpcUaServices) - checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose)); - if (options.CheckGalaxyHostPipe) - checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct)); - - // AvevaSoftService - foreach (var (name, purpose) in SoftServices) - checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose)); - - // AvevaHistorian - if (options.CheckHistorian) - { - foreach (var (name, purpose) in HistorianServices) - checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose)); - } - - return new PrerequisiteReport(checks); - } - - /// - /// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't - /// pay the cost of probing every aa* service when the test only reads gobject rows. - /// - public static async Task CheckRepositoryOnlyAsync( - string? sqlConnectionString = null, CancellationToken ct = default) - { - var checks = new List - { - await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct), - }; - if (checks[0].Status == PrerequisiteStatus.Pass) - checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct)); - return new PrerequisiteReport(checks); - } - - /// - /// Narrower check for the named-pipe endpoint — tests that drive the full Proxy - /// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes - /// (the Host does that work internally; we just need the pipe to accept). - /// - public static async Task CheckGalaxyHostPipeOnlyAsync( - string? pipeName = null, CancellationToken ct = default) - { - var checks = new List - { - await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct), - }; - return new PrerequisiteReport(checks); - } - - /// Knobs for . - public sealed class Options - { - /// SQL Server connection string — defaults to Windows-auth localhost\ZB. - public string? SqlConnectionString { get; init; } - - /// Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to OtOpcUaGalaxy. - public string? GalaxyHostPipeName { get; init; } - - /// Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it. - public bool CheckGalaxyHostPipe { get; init; } = true; - - /// Include Historian service probes. Off by default — Historian is optional. - public bool CheckHistorian { get; init; } = false; - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs deleted file mode 100644 index 626aeac..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Net48Polyfills.cs +++ /dev/null @@ -1,26 +0,0 @@ -#if NET48 -// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't -// provide. Keeps the sources single-target-free at the language level — the same .cs files -// build on both frameworks without preprocessor guards in the callsites. - -namespace System.Runtime.CompilerServices -{ - /// Required by C# 9 init-only setters and record types. - internal static class IsExternalInit { } -} - -namespace System.Runtime.Versioning -{ - /// - /// Minimal shim for the .NET 5+ SupportedOSPlatformAttribute. Pure marker for the - /// compiler on net10; on net48 we still want the attribute to exist so the same - /// [SupportedOSPlatform("windows")] source compiles. The attribute is internal - /// and attribute-targets-everything to minimize surface. - /// - [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)] - internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute - { - public string PlatformName { get; } = platformName; - } -} -#endif diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs deleted file mode 100644 index 095f027..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteCheck.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -/// One prerequisite probe's outcome. returns many of these. -/// Short diagnostic id — e.g. service:aaBootstrap, sql:ZB, registry:ArchestrA.Framework. -/// Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke"). -/// Outcome. -/// One-line specific message an operator can act on — "aaGR not installed — install the Galaxy Repository role from the System Platform setup" beats "failed". -public sealed record PrerequisiteCheck( - string Name, - PrerequisiteCategory Category, - PrerequisiteStatus Status, - string Detail); - -public enum PrerequisiteStatus -{ - /// Prerequisite is met; no action needed. - Pass, - /// Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded. - Warn, - /// Hard dependency missing — live tests can't proceed; surfaces this. - Fail, - /// Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed). - Skip, -} - -public enum PrerequisiteCategory -{ - /// Platform sanity — process bitness, OS platform, DCOM/RPCSS. - Environment, - /// Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc). - AvevaCoreService, - /// Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only. - AvevaSoftService, - /// ArchestrA Framework install markers (registry + files). - AvevaInstall, - /// MXAccess COM server registration + file on disk. - MxAccessCom, - /// SQL Server reachability + ZB database presence + deployed-object count. - GalaxyRepository, - /// Historian services (optional — only required for HistoryRead IPC paths). - AvevaHistorian, - /// OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth). - OtOpcUaService, -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs deleted file mode 100644 index 8c8ff98..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/PrerequisiteReport.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Text; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport; - -/// -/// Aggregated result of an run. Test fixtures -/// typically call to produce the argument for xUnit's -/// Assert.Skip when any hard dependency failed. -/// -public sealed class PrerequisiteReport -{ - public IReadOnlyList Checks { get; } - - public PrerequisiteReport(IEnumerable checks) - { - Checks = [.. checks]; - } - - /// True when every probe is Pass / Warn / Skip — no Fail entries. - public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail); - - /// - /// True when only the AVEVA-side probes pass — ignores failures in the - /// category. Lets a live-test gate - /// say "AVEVA is ready even if the v2 services aren't installed yet" without - /// conflating the two. Useful for tests that exercise Galaxy directly (e.g. - /// ) rather than through our stack. - /// - public bool IsAvevaSideReady => - !Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService); - - /// - /// Multi-line message for Assert.Skip when a hard dependency isn't met. Returns - /// null when is true. - /// - public string? SkipReason - { - get - { - var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList(); - if (fails.Count == 0) return null; - - var sb = new StringBuilder(); - sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):"); - foreach (var f in fails) - sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}"); - sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage."); - return sb.ToString(); - } - } - - /// - /// Human-readable summary of warnings — caller decides whether to log or ignore. Useful - /// when a live test does pass but an operator should know their environment is degraded. - /// - public string? Warnings - { - get - { - var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList(); - if (warns.Count == 0) return null; - - var sb = new StringBuilder(); - sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):"); - foreach (var w in warns) - sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}"); - return sb.ToString(); - } - } - - /// - /// Throw if any - /// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't - /// care about Historian. Call before Assert.Skip if you want to be strict. - /// - public void RequireCategories(params PrerequisiteCategory[] categories) - { - var set = categories.ToHashSet(); - var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList(); - if (fails.Count == 0) return; - - var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}")); - throw new InvalidOperationException($"Required prerequisite categories failed: {detail}"); - } - - public override string ToString() - { - var sb = new StringBuilder(); - sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks"); - foreach (var c in Checks) - sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}"); - return sb.ToString(); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs deleted file mode 100644 index 05a4565..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/MxAccessComProbe.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -/// -/// Confirms MXAccess COM server registration by resolving the -/// LMXProxy.LMXProxyServer ProgID to its CLSID, then checking that the CLSID's -/// 32-bit InprocServer32 entry points at a file that exists on disk. -/// -/// -/// A common failure mode on partial installs: ProgID is registered but the CLSID -/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains). -/// This probe surfaces that case with an actionable message instead of the -/// 0x80040154 REGDB_E_CLASSNOTREG you'd see from a late COM activation failure. -/// -public static class MxAccessComProbe -{ - public const string ProgId = "LMXProxy.LMXProxyServer"; - public const string VersionedProgId = "LMXProxy.LMXProxyServer.1"; - - public static PrerequisiteCheck Check() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Skip, "COM registration probes only run on Windows."); - } - return CheckWindows(); - } - - [SupportedOSPlatform("windows")] - private static PrerequisiteCheck CheckWindows() - { - try - { - var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId); - if (clsid is null) - { - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Fail, - $"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " + - $"Install System Platform's MXAccess component and re-run."); - } - - if (string.IsNullOrWhiteSpace(dll)) - { - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Fail, - $"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " + - $"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir)."); - } - - // Resolve the recorded path — sometimes registered as a bare filename that the COM - // runtime resolves via the current process's DLL-search path. Accept either an - // absolute path that exists, or a bare filename whose resolution we can't verify - // without loading it (treat as Pass-with-note). - if (Path.IsPathRooted(dll)) - { - if (!File.Exists(dll)) - { - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Fail, - $"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " + - $"Re-install the Framework or restore from backup."); - } - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Pass, - $"ProgID {ProgId} → {dll} (file exists)."); - } - - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Pass, - $"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time)."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom, - PrerequisiteStatus.Warn, - $"Probe failed: {ex.GetType().Name}: {ex.Message}"); - } - } - - /// - /// Warn when running as a 64-bit process — MXAccess COM activation will fail with - /// 0x80040154 regardless of registration state. The production drivers run net48 - /// x86; xunit hosts run 64-bit by default so this often surfaces first. - /// - public static PrerequisiteCheck CheckProcessBitness() - { - if (Environment.Is64BitProcess) - { - return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment, - PrerequisiteStatus.Warn, - "Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " + - "the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " + - "talk to the Host service over the named pipe aren't affected."); - } - return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment, - PrerequisiteStatus.Pass, "Test host is 32-bit."); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs deleted file mode 100644 index 1ff25dd..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/NamedPipeProbe.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.IO.Pipes; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -/// -/// Verifies the OtOpcUaGalaxyHost named-pipe endpoint is accepting connections — -/// the handshake the Proxy performs at boot. A clean pipe connect without sending any -/// framed message proves the Host service is listening; we disconnect immediately so we -/// don't consume a session slot. -/// -/// -/// Default pipe name matches the installer script's OTOPCUA_GALAXY_PIPE default. -/// Override when the Host service was installed with a non-default name (custom deployments). -/// -public static class NamedPipeProbe -{ - public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy"; - - public static async Task CheckGalaxyHostPipeAsync( - string? pipeName = null, CancellationToken ct = default) - { - pipeName ??= DefaultGalaxyHostPipeName; - try - { - using var client = new NamedPipeClientStream( - serverName: ".", - pipeName: pipeName, - direction: PipeDirection.InOut, - options: PipeOptions.Asynchronous); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(2)); - await client.ConnectAsync(cts.Token); - - return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, - PrerequisiteStatus.Pass, - $@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening."); - } - catch (OperationCanceledException) - { - return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, - PrerequisiteStatus.Fail, - $@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " + - "Start with: sc.exe start OtOpcUaGalaxyHost"); - } - catch (TimeoutException) - { - return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, - PrerequisiteStatus.Fail, - $@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " + - "Check: sc.exe query OtOpcUaGalaxyHost"); - } - catch (Exception ex) - { - return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService, - PrerequisiteStatus.Fail, - $@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs deleted file mode 100644 index ebcc970..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/RegistryProbe.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using Microsoft.Win32; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -/// -/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install -/// markers. Matches the registered paths documented in -/// docs/v2/implementation/ — System Platform is 32-bit so keys live under -/// HKLM\SOFTWARE\WOW6432Node\ArchestrA\.... -/// -public static class RegistryProbe -{ - // Canonical install roots per the research on our dev box (System Platform 2020 R2). - public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA"; - public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework"; - public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform"; - public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall"; - - public static PrerequisiteCheck CheckFrameworkInstalled() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Skip, "Registry probes only run on Windows."); - } - return FrameworkInstalledWindows(); - } - - public static PrerequisiteCheck CheckPlatformDeployed() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Skip, "Registry probes only run on Windows."); - } - return PlatformDeployedWindows(); - } - - public static PrerequisiteCheck CheckRebootPending() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Skip, "Registry probes only run on Windows."); - } - return RebootPendingWindows(); - } - - [SupportedOSPlatform("windows")] - private static PrerequisiteCheck FrameworkInstalledWindows() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey); - if (key is null) - { - return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Fail, - $"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media."); - } - - var installPath = key.GetValue("InstallPath") as string; - var rootPath = key.GetValue("RootPath") as string; - if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath)) - { - return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"Framework key exists but InstallPath/RootPath values missing — install may be incomplete."); - } - - return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Pass, - $"Installed at {installPath} (RootPath {rootPath})."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"Probe failed: {ex.GetType().Name}: {ex.Message}"); - } - } - - [SupportedOSPlatform("windows")] - private static PrerequisiteCheck PlatformDeployedWindows() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(PlatformKey); - var pfeConfig = key?.GetValue("PfeConfigOptions") as string; - if (string.IsNullOrWhiteSpace(pfeConfig)) - { - return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE."); - } - - // PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..." - // A non-deployed state leaves PlatformId=0 or the key empty. - if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase)) - { - return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests."); - } - - return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Pass, - $"Platform deployed ({pfeConfig})."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"Probe failed: {ex.GetType().Name}: {ex.Message}"); - } - } - - [SupportedOSPlatform("windows")] - private static PrerequisiteCheck RebootPendingWindows() - { - try - { - using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey); - var rebootRequired = key?.GetValue("RebootRequired") as string; - if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase)) - { - return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - "An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot."); - } - return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Pass, - "No pending reboot flagged."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall, - PrerequisiteStatus.Warn, - $"Probe failed: {ex.GetType().Name}: {ex.Message}"); - } - } - - /// - /// Read the registered CLSID for the given ProgID and - /// resolve the 32-bit InprocServer32 file path. Returns null when either is missing. - /// - [SupportedOSPlatform("windows")] - internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId) - { - using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID"); - var clsid = progIdKey?.GetValue(null) as string; - if (string.IsNullOrWhiteSpace(clsid)) return (null, null); - - // 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value. - using var inproc = Registry.LocalMachine.OpenSubKey( - $@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32"); - var dll = inproc?.GetValue(null) as string; - return (clsid, dll); - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs deleted file mode 100644 index 5811197..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/ServiceProbe.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.ServiceProcess; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -/// -/// Queries the Windows Service Control Manager to report whether a named service is -/// installed, its current state, and its start type. Non-Windows hosts return Skip. -/// -public static class ServiceProbe -{ - public static PrerequisiteCheck Check( - string serviceName, - PrerequisiteCategory category, - bool hardRequired, - string whatItDoes) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new PrerequisiteCheck( - Name: $"service:{serviceName}", - Category: category, - Status: PrerequisiteStatus.Skip, - Detail: "Service probes only run on Windows."); - } - - return CheckWindows(serviceName, category, hardRequired, whatItDoes); - } - - [SupportedOSPlatform("windows")] - private static PrerequisiteCheck CheckWindows( - string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes) - { - try - { - using var sc = new ServiceController(serviceName); - // Touch the Status to force the SCM lookup; if the service doesn't exist, this throws - // InvalidOperationException with message "Service ... was not found on computer.". - var status = sc.Status; - var startType = sc.StartType; - - return status switch - { - ServiceControllerStatus.Running => new PrerequisiteCheck( - $"service:{serviceName}", category, PrerequisiteStatus.Pass, - $"Running ({whatItDoes})"), - - // DemandStart services (like NmxSvc) that are Stopped are not necessarily a - // failure — the master service (aaBootstrap) brings them up on demand. Treat - // Stopped+Demand as Warn so operators know the situation but tests still proceed. - ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual => - new PrerequisiteCheck( - $"service:{serviceName}", category, PrerequisiteStatus.Warn, - $"Installed but Stopped (start type Manual — {whatItDoes}). " + - "Will be pulled up on demand by the master service; fine for tests."), - - ServiceControllerStatus.Stopped => Fail( - $"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"), - - _ => new PrerequisiteCheck( - $"service:{serviceName}", category, PrerequisiteStatus.Warn, - $"Transitional state {status} ({whatItDoes}) — try again in a few seconds."), - }; - - PrerequisiteCheck Fail(string detail) => new( - $"service:{serviceName}", category, - hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn, - detail); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase)) - { - return new PrerequisiteCheck( - $"service:{serviceName}", category, - hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn, - $"Not installed ({whatItDoes}). Install the relevant System Platform component and retry."); - } - catch (Exception ex) - { - return new PrerequisiteCheck( - $"service:{serviceName}", category, PrerequisiteStatus.Warn, - $"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown."); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs deleted file mode 100644 index f96d3f9..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/Probes/SqlProbe.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.Data.SqlClient; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes; - -/// -/// Verifies the Galaxy Repository SQL side: SQL Server reachable, ZB database -/// present, and at least one deployed object exists (so live tests have something to read). -/// Reuses the Windows-auth connection string the repo code defaults to. -/// -public static class SqlProbe -{ - public const string DefaultConnectionString = - "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;"; - - public static async Task CheckZbDatabaseAsync( - string? connectionString = null, CancellationToken ct = default) - { - connectionString ??= DefaultConnectionString; - try - { - using var conn = new SqlConnection(connectionString); - await conn.OpenAsync(ct); - - // DB_ID returns null when the database doesn't exist on the connected server — distinct - // failure mode from "server unreachable", deserves a distinct message. - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT DB_ID('ZB')"; - var dbIdObj = await cmd.ExecuteScalarAsync(ct); - if (dbIdObj is null || dbIdObj is DBNull) - { - return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Fail, - "SQL Server reachable but database ZB does not exist. " + - "Create the Galaxy from the IDE or restore a .cab backup."); - } - - return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Pass, "Connected; ZB database exists."); - } - catch (SqlException ex) - { - return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Fail, - $"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Fail, - $"Unexpected probe error: {ex.GetType().Name}: {ex.Message}"); - } - } - - /// - /// Returns the count of deployed Galaxy objects (deployed_version > 0). Zero - /// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful - /// test-suite output — but it IS a warning because any live-read smoke will have - /// nothing to read. - /// - public static async Task CheckDeployedObjectCountAsync( - string? connectionString = null, CancellationToken ct = default) - { - connectionString ??= DefaultConnectionString; - try - { - using var conn = new SqlConnection(connectionString); - await conn.OpenAsync(ct); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0"; - var countObj = await cmd.ExecuteScalarAsync(ct); - var count = countObj is int i ? i : 0; - - return count > 0 - ? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.") - : new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Warn, - "ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " + - "deploy at least a Platform + AppEngine from the IDE to exercise the read path."); - } - catch (Exception ex) - { - return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository, - PrerequisiteStatus.Warn, - $"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj deleted file mode 100644 index 7f61d1d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - - - net10.0;net48 - enable - enable - latest - false - ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport - - - - - - - - - - - - - - - - - - - - - -