From dd3a44930841dceafa8db0739cf19bb43438a496 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 08:35:22 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2018=20=E2=80=94=20delete=20v1?= =?UTF-8?q?=20archived=20projects.=20PR=202=20archived=20via=20IsTestProje?= =?UTF-8?q?ct=3Dfalse=20+=20PropertyGroup=20comment;=20PR=2017=20landed=20?= =?UTF-8?q?the=20full=20v2=20OPC=20UA=20server=20runtime=20(ApplicationCon?= =?UTF-8?q?figuration=20+=20endpoint=20+=20client=20integration=20test);?= =?UTF-8?q?=20every=20v1=20surface=20is=20now=20functionally=20superseded.?= =?UTF-8?q?=20This=20PR=20removes=20the=20archive:=20154=20files=20across?= =?UTF-8?q?=205=20projects=20=E2=80=94=20src/OtOpcUa.Host=20(v1=20server,?= =?UTF-8?q?=20158=20files),=20src/Historian.Aveva=20(v1=20historian=20plug?= =?UTF-8?q?in,=204=20files),=20tests/OtOpcUa.Tests.v1Archive=20(494=20unit?= =?UTF-8?q?=20tests=20that=20were=20archived=20in=20PR=202=20with=20IsTest?= =?UTF-8?q?Project=3Dfalse),=20tests/Historian.Aveva.Tests=20(18=20tests?= =?UTF-8?q?=20against=20the=20v1=20plugin),=20tests/OtOpcUa.IntegrationTes?= =?UTF-8?q?ts=20(6=20tests=20against=20the=20v1=20Host).=20slnx=20trimmed?= =?UTF-8?q?=20to=20reflect=20the=20current=20set=20(12=20src=20+=2012=20te?= =?UTF-8?q?sts).=20Verified=20zero=20incoming=20references=20from=20live?= =?UTF-8?q?=20projects=20before=20deleting=20=E2=80=94=20no=20live=20cspro?= =?UTF-8?q?j=20references=20.Host=20or=20.Historian.Aveva=20since=20PR=205?= =?UTF-8?q?=20ported=20Historian=20into=20Driver.Galaxy.Host/Backend/Histo?= =?UTF-8?q?rian/=20and=20PR=2017=20stood=20up=20the=20new=20OtOpcUa.Server?= =?UTF-8?q?.=20Full=20solution=20post-delete:=200=20errors,=20165=20unit?= =?UTF-8?q?=20+=20integration=20tests=20pass=20(8=20Core=20+=2014=20Proxy?= =?UTF-8?q?=20+=2024=20Configuration=20+=2091=20Galaxy.Host=20+=206=20Shar?= =?UTF-8?q?ed=20+=204=20Server=20+=2018=20Admin)=20=E2=80=94=20no=20regres?= =?UTF-8?q?sions.=20Recovery=20path=20if=20a=20future=20PR=20needs=20to=20?= =?UTF-8?q?resurrect=20a=20specific=20v1=20routine:=20git=20revert=20this?= =?UTF-8?q?=20commit=20or=20cherry-pick=20the=20specific=20file=20from=20p?= =?UTF-8?q?re-delete=20history;=20v1=20is=20preserved=20in=20the=20full=20?= =?UTF-8?q?branch=20history,=20not=20lost.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 5 - .../AvevaHistorianPluginEntry.cs | 15 - .../HistorianClusterEndpointPicker.cs | 181 - .../HistorianDataSource.cs | 704 ---- .../IHistorianConnectionFactory.cs | 81 - .../ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj | 93 - .../Configuration/AlarmFilterConfiguration.cs | 27 - .../Configuration/AppConfiguration.cs | 48 - .../AuthenticationConfiguration.cs | 25 - .../Configuration/ConfigurationValidator.cs | 314 -- .../Configuration/DashboardConfiguration.cs | 23 - .../GalaxyRepositoryConfiguration.cs | 42 - .../Configuration/GalaxyScope.cs | 18 - .../Configuration/HistorianConfiguration.cs | 76 - .../Configuration/LdapConfiguration.cs | 75 - .../Configuration/MxAccessConfiguration.cs | 86 - .../Configuration/OpcUaConfiguration.cs | 64 - .../Configuration/RedundancyConfiguration.cs | 41 - .../SecurityProfileConfiguration.cs | 52 - .../Domain/AlarmObjectFilter.cs | 215 -- .../Domain/ConnectionState.cs | 38 - .../Domain/ConnectionStateChangedEventArgs.cs | 38 - .../Domain/GalaxyAttributeInfo.cs | 76 - .../Domain/GalaxyObjectInfo.cs | 64 - .../Domain/GalaxyRuntimeState.cs | 29 - .../Domain/GalaxyRuntimeStatus.cs | 72 - .../Domain/IGalaxyRepository.cs | 46 - .../Domain/IMxAccessClient.cs | 79 - src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs | 99 - .../Domain/IUserAuthenticationProvider.cs | 41 - .../Domain/LdapAuthenticationProvider.cs | 148 - .../Domain/LmxRoleIds.cs | 18 - .../Domain/MxDataTypeMapper.cs | 87 - .../Domain/MxErrorCodes.cs | 76 - .../Domain/PlatformInfo.cs | 18 - src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs | 122 - .../Domain/QualityMapper.cs | 60 - .../Domain/SecurityClassificationMapper.cs | 30 - src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs | 96 - src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml | 7 - src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd | 176 - .../ChangeDetectionService.cs | 124 - .../GalaxyRepositoryService.cs | 529 --- .../GalaxyRepository/GalaxyRepositoryStats.cs | 40 - .../GalaxyRepository/PlatformScopeFilter.cs | 124 - .../Historian/HistorianAggregateMap.cs | 31 - .../Historian/HistorianClusterNodeState.cs | 49 - .../Historian/HistorianEventDto.cs | 18 - .../Historian/HistorianHealthSnapshot.cs | 97 - .../Historian/HistorianPluginLoader.cs | 180 - .../Historian/HistoryContinuationPoint.cs | 97 - .../Historian/IHistorianDataSource.cs | 40 - .../Metrics/PerformanceMetrics.cs | 265 -- .../MxAccess/GalaxyRuntimeProbeManager.cs | 472 --- .../MxAccess/MxAccessClient.Connection.cs | 149 - .../MxAccess/MxAccessClient.EventHandlers.cs | 97 - .../MxAccess/MxAccessClient.Monitor.cs | 78 - .../MxAccess/MxAccessClient.ReadWrite.cs | 166 - .../MxAccess/MxAccessClient.Subscription.cs | 107 - .../MxAccess/MxAccessClient.cs | 125 - .../MxAccess/MxProxyAdapter.cs | 130 - .../MxAccess/StaComThread.cs | 309 -- .../OpcUa/AddressSpaceBuilder.cs | 224 -- .../OpcUa/AddressSpaceDiff.cs | 132 - .../OpcUa/DataValueConverter.cs | 92 - .../OpcUa/LmxNodeManager.cs | 2924 ----------------- .../OpcUa/LmxOpcUaServer.cs | 528 --- .../OpcUa/OpcUaQualityMapper.cs | 33 - .../OpcUa/OpcUaServerHost.cs | 325 -- .../OpcUa/RedundancyModeResolver.cs | 39 - .../OpcUa/SecurityProfileResolver.cs | 101 - .../OpcUa/ServiceLevelCalculator.cs | 33 - src/ZB.MOM.WW.OtOpcUa.Host/OpcUaService.cs | 532 --- .../OpcUaServiceBuilder.cs | 295 -- src/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 57 - .../Status/HealthCheckService.cs | 141 - .../Status/StatusData.cs | 570 ---- .../Status/StatusReportService.cs | 644 ---- .../Status/StatusWebServer.cs | 189 -- .../Utilities/SyncOverAsync.cs | 53 - .../ZB.MOM.WW.OtOpcUa.Host.csproj | 71 - src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json | 91 - .../FakeHistorianConnectionFactory.cs | 63 - .../HistorianClusterEndpointPickerTests.cs | 291 -- .../HistorianClusterFailoverTests.cs | 166 - .../HistorianDataSourceLifecycleTests.cs | 281 -- ...OM.WW.OtOpcUa.Historian.Aveva.Tests.csproj | 41 - .../GalaxyRepositoryServiceTests.cs | 129 - .../ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj | 45 - .../appsettings.test.json | 5 - .../xunit.runner.json | 4 - .../Authentication/UserAuthenticationTests.cs | 231 -- .../ConfigurationLoadingTests.cs | 427 --- .../HistorianConfigurationTests.cs | 65 - .../Domain/AlarmObjectFilterTests.cs | 416 --- .../Domain/GalaxyAttributeInfoTests.cs | 63 - .../Domain/MxDataTypeMapperTests.cs | 97 - .../Domain/MxErrorCodesTests.cs | 65 - .../Domain/QualityMapperTests.cs | 149 - .../SecurityClassificationMapperTests.cs | 54 - .../EndToEnd/FullDataFlowTest.cs | 127 - .../ChangeDetectionServiceTests.cs | 118 - .../PlatformScopeFilterTests.cs | 196 -- .../Helpers/FakeAuthenticationProvider.cs | 35 - .../Helpers/FakeGalaxyRepository.cs | 97 - .../Helpers/FakeMxAccessClient.cs | 185 -- .../Helpers/FakeMxProxy.cs | 210 -- .../Helpers/OpcUaServerFixture.cs | 195 -- .../Helpers/OpcUaServerFixtureTests.cs | 119 - .../Helpers/OpcUaTestClient.cs | 287 -- .../Helpers/TestData.cs | 120 - .../Historian/HistorianAggregateMapTests.cs | 59 - .../Historian/HistorianPluginLoaderTests.cs | 45 - .../Historian/HistorianQualityMappingTests.cs | 46 - .../HistoryContinuationPointTests.cs | 142 - .../Integration/AccessLevelTests.cs | 166 - .../Integration/AddressSpaceRebuildTests.cs | 382 --- .../AlarmObjectFilterIntegrationTests.cs | 206 -- .../Integration/ArrayWriteTests.cs | 203 -- .../Integration/HistorizingFlagTests.cs | 101 - .../Integration/IncrementalSyncTests.cs | 202 -- .../Integration/MultiClientTests.cs | 430 --- .../Integration/PermissionEnforcementTests.cs | 213 -- .../Integration/RedundancyTests.cs | 188 -- .../Metrics/PerformanceMetricsTests.cs | 153 - .../GalaxyRuntimeProbeManagerTests.cs | 547 --- .../MxAccess/MxAccessClientConnectionTests.cs | 146 - .../MxAccess/MxAccessClientMonitorTests.cs | 173 - .../MxAccess/MxAccessClientReadWriteTests.cs | 193 -- .../MxAccessClientSubscriptionTests.cs | 229 -- .../MxAccess/StaComThreadTests.cs | 124 - .../OpcUa/AddressSpaceDiffTests.cs | 209 -- .../OpcUa/DataValueConverterTests.cs | 165 - .../OpcUa/LmxNodeManagerBuildTests.cs | 184 -- .../OpcUa/LmxNodeManagerRebuildTests.cs | 92 - .../LmxNodeManagerSubscriptionFaultTests.cs | 104 - .../OpcUa/OpcUaQualityMapperTests.cs | 84 - .../RedundancyConfigurationTests.cs | 44 - .../Redundancy/RedundancyModeResolverTests.cs | 54 - .../Redundancy/ServiceLevelCalculatorTests.cs | 59 - .../SampleTest.cs | 20 - .../SecurityProfileConfigurationTests.cs | 52 - .../Security/SecurityProfileResolverTests.cs | 140 - .../Status/HealthCheckServiceTests.cs | 212 -- .../Status/StatusReportServiceTests.cs | 428 --- .../Status/StatusWebServerTests.cs | 173 - .../Utilities/SyncOverAsyncTests.cs | 72 - .../ChangeDetectionToRebuildWiringTest.cs | 58 - .../Wiring/MxAccessToNodeManagerWiringTest.cs | 54 - .../Wiring/OpcUaReadToMxAccessWiringTest.cs | 59 - .../OpcUaServiceDashboardFailureTests.cs | 78 - .../Wiring/OpcUaWriteToMxAccessWiringTest.cs | 52 - .../Wiring/ServiceStartupSequenceTest.cs | 149 - .../Wiring/ShutdownCompletesTest.cs | 41 - .../ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj | 56 - 155 files changed, 24774 deletions(-) delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUaService.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/OpcUaServiceBuilder.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Program.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Status/HealthCheckService.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusData.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusReportService.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusWebServer.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/Utilities/SyncOverAsync.cs delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj delete mode 100644 src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/FakeHistorianConnectionFactory.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterEndpointPickerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterFailoverTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianDataSourceLifecycleTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/appsettings.test.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/xunit.runner.json delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Authentication/UserAuthenticationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/ConfigurationLoadingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/HistorianConfigurationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/AlarmObjectFilterTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/GalaxyAttributeInfoTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxDataTypeMapperTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxErrorCodesTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/QualityMapperTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/SecurityClassificationMapperTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/EndToEnd/FullDataFlowTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/ChangeDetectionServiceTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/PlatformScopeFilterTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeAuthenticationProvider.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeGalaxyRepository.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxAccessClient.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxProxy.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixture.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixtureTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaTestClient.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/TestData.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianAggregateMapTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianPluginLoaderTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianQualityMappingTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistoryContinuationPointTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AccessLevelTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AddressSpaceRebuildTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AlarmObjectFilterIntegrationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/ArrayWriteTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/HistorizingFlagTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/IncrementalSyncTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/MultiClientTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/PermissionEnforcementTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/RedundancyTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Metrics/PerformanceMetricsTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/GalaxyRuntimeProbeManagerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientConnectionTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientMonitorTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientReadWriteTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientSubscriptionTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/StaComThreadTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/AddressSpaceDiffTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/DataValueConverterTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerBuildTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerRebuildTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerSubscriptionFaultTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/OpcUaQualityMapperTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyConfigurationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyModeResolverTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/ServiceLevelCalculatorTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/SampleTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileConfigurationTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileResolverTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/HealthCheckServiceTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusReportServiceTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusWebServerTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Utilities/SyncOverAsyncTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ChangeDetectionToRebuildWiringTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/MxAccessToNodeManagerWiringTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaReadToMxAccessWiringTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaServiceDashboardFailureTests.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaWriteToMxAccessWiringTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ServiceStartupSequenceTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ShutdownCompletesTest.cs delete mode 100644 tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index d75c853..253e325 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -8,8 +8,6 @@ - - @@ -24,9 +22,6 @@ - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs deleted file mode 100644 index 99321be..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/AvevaHistorianPluginEntry.cs +++ /dev/null @@ -1,15 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva -{ - /// - /// Reflection entry point invoked by HistorianPluginLoader in the Host. Kept - /// deliberately simple so the plugin contract is a single static factory method. - /// - public static class AvevaHistorianPluginEntry - { - public static IHistorianDataSource Create(HistorianConfiguration config) - => new HistorianDataSource(config); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs deleted file mode 100644 index 8357ee0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianClusterEndpointPicker.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva -{ - /// - /// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which - /// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands - /// out an ordered list of eligible candidates for the data source to try in sequence. - /// - /// - /// Design notes: - /// - /// No SDK dependency — fully unit-testable with an injected clock. - /// Per-node state is guarded by a single lock; operations are microsecond-scale - /// so contention is a non-issue. - /// Cooldown is purely passive: a node re-enters the healthy pool the next time - /// it is queried after its cooldown window elapses. There is no background probe. - /// Nodes are returned in configuration order so operators can express a - /// preference (primary first, fallback second). - /// When is empty, the picker is - /// initialized with a single entry from - /// so legacy deployments continue to work unchanged. - /// - /// - internal sealed class HistorianClusterEndpointPicker - { - private readonly Func _clock; - private readonly TimeSpan _cooldown; - private readonly object _lock = new object(); - private readonly List _nodes; - - public HistorianClusterEndpointPicker(HistorianConfiguration config) - : this(config, () => DateTime.UtcNow) { } - - internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func clock) - { - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - _cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds)); - - var names = (config.ServerNames != null && config.ServerNames.Count > 0) - ? config.ServerNames - : new List { config.ServerName }; - - _nodes = names - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Select(n => n.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(n => new NodeEntry { Name = n }) - .ToList(); - } - - /// - /// Gets the total number of configured cluster nodes. Stable — nodes are never added - /// or removed after construction. - /// - public int NodeCount - { - get - { - lock (_lock) - return _nodes.Count; - } - } - - /// - /// Returns an ordered snapshot of nodes currently eligible for a connection attempt, - /// with any node whose cooldown has elapsed automatically restored to the pool. - /// An empty list means all nodes are in active cooldown. - /// - public IReadOnlyList GetHealthyNodes() - { - lock (_lock) - { - var now = _clock(); - return _nodes - .Where(n => IsHealthyAt(n, now)) - .Select(n => n.Name) - .ToList(); - } - } - - /// - /// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown). - /// - public int HealthyNodeCount - { - get - { - lock (_lock) - { - var now = _clock(); - return _nodes.Count(n => IsHealthyAt(n, now)); - } - } - } - - /// - /// Places into cooldown starting at the current clock time. - /// Increments the node's failure counter and stores the latest error message for - /// surfacing on the dashboard. Unknown node names are ignored. - /// - public void MarkFailed(string node, string? error) - { - lock (_lock) - { - var entry = FindEntry(node); - if (entry == null) - return; - - var now = _clock(); - entry.FailureCount++; - entry.LastError = error; - entry.LastFailureTime = now; - entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null; - } - } - - /// - /// Marks as healthy immediately — clears any active cooldown but - /// leaves the cumulative failure counter intact for operator diagnostics. Unknown node - /// names are ignored. - /// - public void MarkHealthy(string node) - { - lock (_lock) - { - var entry = FindEntry(node); - if (entry == null) - return; - entry.CooldownUntil = null; - } - } - - /// - /// Captures the current per-node state for the health dashboard. Freshly computed from - /// so recently-expired cooldowns are reported as healthy. - /// - public List SnapshotNodeStates() - { - lock (_lock) - { - var now = _clock(); - return _nodes.Select(n => new HistorianClusterNodeState - { - Name = n.Name, - IsHealthy = IsHealthyAt(n, now), - CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil, - FailureCount = n.FailureCount, - LastError = n.LastError, - LastFailureTime = n.LastFailureTime - }).ToList(); - } - } - - private static bool IsHealthyAt(NodeEntry entry, DateTime now) - { - return entry.CooldownUntil == null || entry.CooldownUntil <= now; - } - - private NodeEntry? FindEntry(string node) - { - for (var i = 0; i < _nodes.Count; i++) - if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase)) - return _nodes[i]; - return null; - } - - private sealed class NodeEntry - { - public string Name { get; set; } = ""; - public DateTime? CooldownUntil { get; set; } - public int FailureCount { get; set; } - public string? LastError { get; set; } - public DateTime? LastFailureTime { get; set; } - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs deleted file mode 100644 index 489005c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/HistorianDataSource.cs +++ /dev/null @@ -1,704 +0,0 @@ -using System; -using System.Collections.Generic; -using StringCollection = System.Collections.Specialized.StringCollection; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA; -using Opc.Ua; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva -{ - /// - /// Reads historical data from the Wonderware Historian via the aahClientManaged SDK. - /// - public sealed class HistorianDataSource : IHistorianDataSource - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly HistorianConfiguration _config; - private readonly object _connectionLock = new object(); - private readonly object _eventConnectionLock = new object(); - private readonly IHistorianConnectionFactory _factory; - private HistorianAccess? _connection; - private HistorianAccess? _eventConnection; - private bool _disposed; - - // Runtime query health state. Guarded by _healthLock — updated on every read - // method exit (success or failure) so the dashboard can distinguish "plugin - // loaded but never queried" from "plugin loaded and queries are failing". - private readonly object _healthLock = new object(); - private long _totalSuccesses; - private long _totalFailures; - private int _consecutiveFailures; - private DateTime? _lastSuccessTime; - private DateTime? _lastFailureTime; - private string? _lastError; - private string? _activeProcessNode; - private string? _activeEventNode; - - // Cluster endpoint picker — shared across process + event paths so a node that - // fails on one silo is skipped on the other. Initialized from config at construction. - private readonly HistorianClusterEndpointPicker _picker; - - /// - /// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries. - /// - /// The Historian SDK connection settings used for runtime history lookups. - public HistorianDataSource(HistorianConfiguration config) - : this(config, new SdkHistorianConnectionFactory(), null) { } - - /// - /// Initializes a Historian reader with a custom connection factory for testing. When - /// is a new picker is built from - /// , preserving backward compatibility with existing tests. - /// - internal HistorianDataSource( - HistorianConfiguration config, - IHistorianConnectionFactory factory, - HistorianClusterEndpointPicker? picker = null) - { - _config = config; - _factory = factory; - _picker = picker ?? new HistorianClusterEndpointPicker(config); - } - - /// - /// Iterates the picker's healthy node list, cloning the configuration per attempt and - /// handing it to the factory. Marks each tried node as healthy on success or failed on - /// exception. Returns the winning connection + node name; throws when no nodes succeed. - /// - private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type) - { - var candidates = _picker.GetHealthyNodes(); - if (candidates.Count == 0) - { - var total = _picker.NodeCount; - throw new InvalidOperationException( - total == 0 - ? "No historian nodes configured" - : $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to"); - } - - Exception? lastException = null; - foreach (var node in candidates) - { - var attemptConfig = CloneConfigWithServerName(node); - try - { - var conn = _factory.CreateAndConnect(attemptConfig, type); - _picker.MarkHealthy(node); - return (conn, node); - } - catch (Exception ex) - { - _picker.MarkFailed(node, ex.Message); - lastException = ex; - Log.Warning(ex, - "Historian node {Node} failed during connect attempt; trying next candidate", node); - } - } - - var inner = lastException?.Message ?? "(no detail)"; - throw new InvalidOperationException( - $"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}", - lastException); - } - - private HistorianConfiguration CloneConfigWithServerName(string serverName) - { - return new HistorianConfiguration - { - Enabled = _config.Enabled, - ServerName = serverName, - ServerNames = _config.ServerNames, - FailureCooldownSeconds = _config.FailureCooldownSeconds, - IntegratedSecurity = _config.IntegratedSecurity, - UserName = _config.UserName, - Password = _config.Password, - Port = _config.Port, - CommandTimeoutSeconds = _config.CommandTimeoutSeconds, - MaxValuesPerRead = _config.MaxValuesPerRead - }; - } - - /// - public HistorianHealthSnapshot GetHealthSnapshot() - { - var nodeStates = _picker.SnapshotNodeStates(); - var healthyCount = 0; - foreach (var n in nodeStates) - if (n.IsHealthy) - healthyCount++; - - lock (_healthLock) - { - return new HistorianHealthSnapshot - { - TotalQueries = _totalSuccesses + _totalFailures, - TotalSuccesses = _totalSuccesses, - TotalFailures = _totalFailures, - ConsecutiveFailures = _consecutiveFailures, - LastSuccessTime = _lastSuccessTime, - LastFailureTime = _lastFailureTime, - LastError = _lastError, - ProcessConnectionOpen = Volatile.Read(ref _connection) != null, - EventConnectionOpen = Volatile.Read(ref _eventConnection) != null, - ActiveProcessNode = _activeProcessNode, - ActiveEventNode = _activeEventNode, - NodeCount = nodeStates.Count, - HealthyNodeCount = healthyCount, - Nodes = nodeStates - }; - } - } - - private void RecordSuccess() - { - lock (_healthLock) - { - _totalSuccesses++; - _lastSuccessTime = DateTime.UtcNow; - _consecutiveFailures = 0; - _lastError = null; - } - } - - private void RecordFailure(string error) - { - lock (_healthLock) - { - _totalFailures++; - _lastFailureTime = DateTime.UtcNow; - _consecutiveFailures++; - _lastError = error; - } - } - - private void EnsureConnected() - { - if (_disposed) - throw new ObjectDisposedException(nameof(HistorianDataSource)); - - // Fast path: already connected (no lock needed) - if (Volatile.Read(ref _connection) != null) - return; - - // Create and wait for connection outside the lock so concurrent history - // requests are not serialized behind a slow Historian handshake. The cluster - // picker iterates configured nodes and returns the first that successfully connects. - var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process); - - lock (_connectionLock) - { - if (_disposed) - { - conn.CloseConnection(out _); - conn.Dispose(); - throw new ObjectDisposedException(nameof(HistorianDataSource)); - } - - if (_connection != null) - { - // Another thread connected while we were waiting - conn.CloseConnection(out _); - conn.Dispose(); - return; - } - - _connection = conn; - lock (_healthLock) - _activeProcessNode = winningNode; - Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port); - } - } - - private void HandleConnectionError(Exception? ex = null) - { - lock (_connectionLock) - { - if (_connection == null) - return; - - try - { - _connection.CloseConnection(out _); - _connection.Dispose(); - } - catch (Exception disposeEx) - { - Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery"); - } - - _connection = null; - string? failedNode; - lock (_healthLock) - { - failedNode = _activeProcessNode; - _activeProcessNode = null; - } - - if (failedNode != null) - _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure"); - Log.Warning(ex, "Historian SDK connection reset (node={Node}) — will reconnect on next request", - failedNode ?? "(unknown)"); - } - } - - private void EnsureEventConnected() - { - if (_disposed) - throw new ObjectDisposedException(nameof(HistorianDataSource)); - - if (Volatile.Read(ref _eventConnection) != null) - return; - - var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event); - - lock (_eventConnectionLock) - { - if (_disposed) - { - conn.CloseConnection(out _); - conn.Dispose(); - throw new ObjectDisposedException(nameof(HistorianDataSource)); - } - - if (_eventConnection != null) - { - conn.CloseConnection(out _); - conn.Dispose(); - return; - } - - _eventConnection = conn; - lock (_healthLock) - _activeEventNode = winningNode; - Log.Information("Historian SDK event connection opened to {Server}:{Port}", - winningNode, _config.Port); - } - } - - private void HandleEventConnectionError(Exception? ex = null) - { - lock (_eventConnectionLock) - { - if (_eventConnection == null) - return; - - try - { - _eventConnection.CloseConnection(out _); - _eventConnection.Dispose(); - } - catch (Exception disposeEx) - { - Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery"); - } - - _eventConnection = null; - string? failedNode; - lock (_healthLock) - { - failedNode = _activeEventNode; - _activeEventNode = null; - } - - if (failedNode != null) - _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure"); - Log.Warning(ex, "Historian SDK event connection reset (node={Node}) — will reconnect on next request", - failedNode ?? "(unknown)"); - } - } - - - /// - public Task> ReadRawAsync( - string tagName, DateTime startTime, DateTime endTime, int maxValues, - CancellationToken ct = default) - { - var results = new List(); - - try - { - EnsureConnected(); - - using var query = _connection!.CreateHistoryQuery(); - var args = new HistoryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = startTime, - EndDateTime = endTime, - RetrievalMode = HistorianRetrievalMode.Full - }; - - if (maxValues > 0) - args.BatchSize = (uint)maxValues; - else if (_config.MaxValuesPerRead > 0) - args.BatchSize = (uint)_config.MaxValuesPerRead; - - if (!query.StartQuery(args, out var error)) - { - Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode); - RecordFailure($"raw StartQuery: {error.ErrorCode}"); - HandleConnectionError(); - return Task.FromResult(results); - } - - var count = 0; - var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead; - - while (query.MoveNext(out error)) - { - ct.ThrowIfCancellationRequested(); - - var result = query.QueryResult; - var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc); - - object? value; - if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0) - value = result.StringValue; - else - value = result.Value; - - var quality = (byte)(result.OpcQuality & 0xFF); - - results.Add(new DataValue - { - Value = new Variant(value), - SourceTimestamp = timestamp, - ServerTimestamp = timestamp, - StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality)) - }); - - count++; - if (limit > 0 && count >= limit) - break; - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) - { - throw; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName); - RecordFailure($"raw: {ex.Message}"); - HandleConnectionError(ex); - } - - Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})", - tagName, results.Count, startTime, endTime); - - return Task.FromResult(results); - } - - /// - public Task> ReadAggregateAsync( - string tagName, DateTime startTime, DateTime endTime, - double intervalMs, string aggregateColumn, - CancellationToken ct = default) - { - var results = new List(); - - try - { - EnsureConnected(); - - using var query = _connection!.CreateAnalogSummaryQuery(); - var args = new AnalogSummaryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = startTime, - EndDateTime = endTime, - Resolution = (ulong)intervalMs - }; - - if (!query.StartQuery(args, out var error)) - { - Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName, - error.ErrorCode); - RecordFailure($"aggregate StartQuery: {error.ErrorCode}"); - HandleConnectionError(); - return Task.FromResult(results); - } - - while (query.MoveNext(out error)) - { - ct.ThrowIfCancellationRequested(); - - var result = query.QueryResult; - var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc); - var value = ExtractAggregateValue(result, aggregateColumn); - - results.Add(new DataValue - { - Value = new Variant(value), - SourceTimestamp = timestamp, - ServerTimestamp = timestamp, - StatusCode = value != null ? StatusCodes.Good : StatusCodes.BadNoData - }); - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) - { - throw; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName); - RecordFailure($"aggregate: {ex.Message}"); - HandleConnectionError(ex); - } - - Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values", - aggregateColumn, tagName, results.Count); - - return Task.FromResult(results); - } - - /// - public Task> ReadAtTimeAsync( - string tagName, DateTime[] timestamps, - CancellationToken ct = default) - { - var results = new List(); - - if (timestamps == null || timestamps.Length == 0) - return Task.FromResult(results); - - try - { - EnsureConnected(); - - foreach (var timestamp in timestamps) - { - ct.ThrowIfCancellationRequested(); - - using var query = _connection!.CreateHistoryQuery(); - var args = new HistoryQueryArgs - { - TagNames = new StringCollection { tagName }, - StartDateTime = timestamp, - EndDateTime = timestamp, - RetrievalMode = HistorianRetrievalMode.Interpolated, - BatchSize = 1 - }; - - if (!query.StartQuery(args, out var error)) - { - results.Add(new DataValue - { - Value = Variant.Null, - SourceTimestamp = timestamp, - ServerTimestamp = timestamp, - StatusCode = StatusCodes.BadNoData - }); - continue; - } - - if (query.MoveNext(out error)) - { - var result = query.QueryResult; - object? value; - if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0) - value = result.StringValue; - else - value = result.Value; - - var quality = (byte)(result.OpcQuality & 0xFF); - results.Add(new DataValue - { - Value = new Variant(value), - SourceTimestamp = timestamp, - ServerTimestamp = timestamp, - StatusCode = QualityMapper.MapToOpcUaStatusCode( - QualityMapper.MapFromMxAccessQuality(quality)) - }); - } - else - { - results.Add(new DataValue - { - Value = Variant.Null, - SourceTimestamp = timestamp, - ServerTimestamp = timestamp, - StatusCode = StatusCodes.BadNoData - }); - } - - query.EndQuery(out _); - } - RecordSuccess(); - } - catch (OperationCanceledException) - { - throw; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName); - RecordFailure($"at-time: {ex.Message}"); - HandleConnectionError(ex); - } - - Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps", - tagName, results.Count, timestamps.Length); - - return Task.FromResult(results); - } - - /// - public Task> ReadEventsAsync( - string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, - CancellationToken ct = default) - { - var results = new List(); - - try - { - EnsureEventConnected(); - - using var query = _eventConnection!.CreateEventQuery(); - var args = new EventQueryArgs - { - StartDateTime = startTime, - EndDateTime = endTime, - EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead, - QueryType = HistorianEventQueryType.Events, - EventOrder = HistorianEventOrder.Ascending - }; - - if (!string.IsNullOrEmpty(sourceName)) - { - query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _); - } - - if (!query.StartQuery(args, out var error)) - { - Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode); - RecordFailure($"events StartQuery: {error.ErrorCode}"); - HandleEventConnectionError(); - return Task.FromResult(results); - } - - var count = 0; - while (query.MoveNext(out error)) - { - ct.ThrowIfCancellationRequested(); - results.Add(ToDto(query.QueryResult)); - count++; - if (maxEvents > 0 && count >= maxEvents) - break; - } - - query.EndQuery(out _); - RecordSuccess(); - } - catch (OperationCanceledException) - { - throw; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)"); - RecordFailure($"events: {ex.Message}"); - HandleEventConnectionError(ex); - } - - Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})", - sourceName ?? "(all)", results.Count, startTime, endTime); - - return Task.FromResult(results); - } - - private static HistorianEventDto ToDto(HistorianEvent evt) - { - return new HistorianEventDto - { - Id = evt.Id, - Source = evt.Source, - EventTime = evt.EventTime, - ReceivedTime = evt.ReceivedTime, - DisplayText = evt.DisplayText, - Severity = (ushort)evt.Severity - }; - } - - /// - /// Extracts the requested aggregate value from an by column name. - /// - internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column) - { - switch (column) - { - case "Average": return result.Average; - case "Minimum": return result.Minimum; - case "Maximum": return result.Maximum; - case "ValueCount": return result.ValueCount; - case "First": return result.First; - case "Last": return result.Last; - case "StdDev": return result.StdDev; - default: return null; - } - } - - /// - /// Closes the Historian SDK connection and releases resources. - /// - public void Dispose() - { - if (_disposed) - return; - _disposed = true; - - try - { - _connection?.CloseConnection(out _); - _connection?.Dispose(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error closing Historian SDK connection"); - } - - try - { - _eventConnection?.CloseConnection(out _); - _eventConnection?.Dispose(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error closing Historian SDK event connection"); - } - - _connection = null; - _eventConnection = null; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs deleted file mode 100644 index 3254bfb..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/IHistorianConnectionFactory.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Threading; -using ArchestrA; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva -{ - /// - /// Creates and opens Historian SDK connections. Extracted so tests can inject - /// fakes that control connection success, failure, and timeout behavior. - /// - internal interface IHistorianConnectionFactory - { - /// - /// Creates a new Historian SDK connection, opens it, and waits until it is ready. - /// Throws on connection failure or timeout. - /// - HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type); - } - - /// - /// Production implementation that creates real Historian SDK connections. - /// - internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory - { - public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type) - { - var conn = new HistorianAccess(); - - var args = new HistorianConnectionArgs - { - ServerName = config.ServerName, - TcpPort = (ushort)config.Port, - IntegratedSecurity = config.IntegratedSecurity, - UseArchestrAUser = config.IntegratedSecurity, - ConnectionType = type, - ReadOnly = true, - PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000) - }; - - if (!config.IntegratedSecurity) - { - args.UserName = config.UserName ?? string.Empty; - args.Password = config.Password ?? string.Empty; - } - - if (!conn.OpenConnection(args, out var error)) - { - conn.Dispose(); - throw new InvalidOperationException( - $"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}"); - } - - // The SDK connects asynchronously — poll until the connection is ready - var timeoutMs = config.CommandTimeoutSeconds * 1000; - var elapsed = 0; - while (elapsed < timeoutMs) - { - var status = new HistorianConnectionStatus(); - conn.GetConnectionStatus(ref status); - - if (status.ConnectedToServer) - return conn; - - if (status.ErrorOccurred) - { - conn.Dispose(); - throw new InvalidOperationException( - $"Historian SDK connection failed: {status.Error}"); - } - - Thread.Sleep(250); - elapsed += 250; - } - - conn.Dispose(); - throw new TimeoutException( - $"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s"); - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj b/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj deleted file mode 100644 index 4c05a0b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj +++ /dev/null @@ -1,93 +0,0 @@ - - - - net48 - x86 - 9.0 - enable - ZB.MOM.WW.OtOpcUa.Historian.Aveva - ZB.MOM.WW.OtOpcUa.Historian.Aveva - - false - - $(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\ - - - - - - - - - - - - - - - - - - - false - true - - - - - - - ..\..\lib\aahClientManaged.dll - false - - - ..\..\lib\aahClientCommon.dll - false - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - - - <_HistorianStageFiles Include="$(OutDir)aahClient.dll"/> - <_HistorianStageFiles Include="$(OutDir)aahClientCommon.dll"/> - <_HistorianStageFiles Include="$(OutDir)aahClientManaged.dll"/> - <_HistorianStageFiles Include="$(OutDir)Historian.CBE.dll"/> - <_HistorianStageFiles Include="$(OutDir)Historian.DPAPI.dll"/> - <_HistorianStageFiles Include="$(OutDir)ArchestrA.CloudHistorian.Contract.dll"/> - <_HistorianStageFiles Include="$(OutDir)$(AssemblyName).dll"/> - <_HistorianStageFiles Include="$(OutDir)$(AssemblyName).pdb" Condition="Exists('$(OutDir)$(AssemblyName).pdb')"/> - - - - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs deleted file mode 100644 index 37ac945..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmFilterConfiguration.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Configures the template-based alarm object filter under OpcUa.AlarmFilter. - /// - /// - /// Each entry in is a wildcard pattern matched against the template - /// derivation chain of every Galaxy object. Supported wildcard: *. Matching is case-insensitive - /// and the leading $ used by Galaxy template tag_names is normalized away, so operators can - /// write TestMachine* instead of $TestMachine*. An entry may itself contain comma-separated - /// patterns for convenience (e.g., "TestMachine*, Pump_*"). An empty list disables the filter, - /// restoring current behavior: all alarm-bearing objects are monitored when - /// is . - /// - public class AlarmFilterConfiguration - { - /// - /// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions. - /// An object is included when any template in its derivation chain matches any pattern, and the - /// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated - /// once: overlapping matches never create duplicate alarm subscriptions. - /// - public List ObjectFilters { get; set; } = new(); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs deleted file mode 100644 index 0d6ca24..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AppConfiguration.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Top-level configuration holder binding all sections from appsettings.json. (SVC-003) - /// - public class AppConfiguration - { - /// - /// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space. - /// - public OpcUaConfiguration OpcUa { get; set; } = new(); - - /// - /// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes. - /// - public MxAccessConfiguration MxAccess { get; set; } = new(); - - /// - /// Gets or sets the repository settings used to query Galaxy metadata for address-space construction. - /// - public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new(); - - /// - /// Gets or sets the embedded dashboard settings used to surface service health to operators. - /// - public DashboardConfiguration Dashboard { get; set; } = new(); - - /// - /// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data. - /// - public HistorianConfiguration Historian { get; set; } = new(); - - /// - /// Gets or sets the authentication and role-based access control settings. - /// - public AuthenticationConfiguration Authentication { get; set; } = new(); - - /// - /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed. - /// - public SecurityProfileConfiguration Security { get; set; } = new(); - - /// - /// Gets or sets the redundancy settings that control how this server participates in a redundant pair. - /// - public RedundancyConfiguration Redundancy { get; set; } = new(); - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs deleted file mode 100644 index e7978ae..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/AuthenticationConfiguration.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Authentication and role-based access control settings for the OPC UA server. - /// - public class AuthenticationConfiguration - { - /// - /// Gets or sets a value indicating whether anonymous OPC UA connections are accepted. - /// - public bool AllowAnonymous { get; set; } = true; - - /// - /// Gets or sets a value indicating whether anonymous users can write tag values. - /// When false, only authenticated users can write. Existing security classification restrictions still apply. - /// - public bool AnonymousCanWrite { get; set; } = true; - - /// - /// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true, - /// credentials are validated against the LDAP server and group membership determines permissions. - /// - public LdapConfiguration Ldap { get; set; } = new(); - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs deleted file mode 100644 index 248f106..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigurationValidator.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.SqlClient; -using System.Linq; -using Opc.Ua; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Validates and logs effective configuration at startup. (SVC-003, SVC-005) - /// - public static class ConfigurationValidator - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); - - /// - /// Validates the effective host configuration and writes the resolved values to the startup log before service - /// initialization continues. - /// - /// - /// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, - /// and dashboard behavior. - /// - /// - /// when the required settings are present and within supported bounds; otherwise, - /// . - /// - public static bool ValidateAndLog(AppConfiguration config) - { - var valid = true; - - Log.Information("=== Effective Configuration ==="); - - // OPC UA - Log.Information( - "OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}", - config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, - config.OpcUa.GalaxyName); - Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}", - config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes); - - if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535) - { - Log.Error("OpcUa.Port must be between 1 and 65535"); - valid = false; - } - - if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName)) - { - Log.Error("OpcUa.GalaxyName must not be empty"); - valid = false; - } - - // Alarm filter - var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0; - Log.Information( - "OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]", - config.OpcUa.AlarmTrackingEnabled, - alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters)); - if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled) - Log.Warning( - "OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect", - alarmFilterCount); - - // MxAccess - Log.Information( - "MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}", - config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds, - config.MxAccess.MaxConcurrentOperations); - Log.Information( - "MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s", - config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect, - config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds); - Log.Information( - "MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s", - config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds, - config.MxAccess.RequestTimeoutSeconds); - - if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName)) - { - Log.Error("MxAccess.ClientName must not be empty"); - valid = false; - } - - if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5) - Log.Warning( - "MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback", - config.MxAccess.RuntimeStatusUnknownTimeoutSeconds); - - if (config.MxAccess.RequestTimeoutSeconds < 1) - { - Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1"); - valid = false; - } - else if (config.MxAccess.RequestTimeoutSeconds < - Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds)) - { - Log.Warning( - "MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path", - config.MxAccess.RequestTimeoutSeconds, - config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds); - } - - // Galaxy Repository - Log.Information( - "GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}", - SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds, - config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes); - - var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName) - ? Environment.MachineName - : config.GalaxyRepository.PlatformName; - Log.Information( - "GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}", - config.GalaxyRepository.Scope, - config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform - ? effectivePlatformName - : "(n/a)"); - - if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform && - string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)) - Log.Information( - "GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'", - Environment.MachineName); - - if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString)) - { - Log.Error("GalaxyRepository.ConnectionString must not be empty"); - valid = false; - } - - // Dashboard - Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s", - config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds); - - // Security - Log.Information( - "Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}", - string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates, - config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize); - - Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)"); - Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)"); - Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths); - - var unknownProfiles = config.Security.Profiles - .Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase)) - .ToList(); - if (unknownProfiles.Count > 0) - Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}", - string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames)); - - if (config.Security.MinimumCertificateKeySize < 2048) - { - Log.Error("Security.MinimumCertificateKeySize must be at least 2048"); - valid = false; - } - - if (config.Security.AutoAcceptClientCertificates) - Log.Warning( - "Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production"); - - if (config.Security.Profiles.Count == 1 && - config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase)) - Log.Warning("Only the 'None' security profile is configured — transport security is disabled"); - - // Historian - var clusterNodes = config.Historian.ServerNames ?? new List(); - var effectiveNodes = clusterNodes.Count > 0 - ? string.Join(",", clusterNodes) - : config.Historian.ServerName; - Log.Information( - "Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}", - config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity, - config.Historian.Port); - Log.Information( - "Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}", - config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead, - config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds); - - if (config.Historian.Enabled) - { - if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName)) - { - Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled"); - valid = false; - } - - if (config.Historian.FailureCooldownSeconds < 0) - { - Log.Error("Historian.FailureCooldownSeconds must be zero or positive"); - valid = false; - } - - if (config.Historian.RequestTimeoutSeconds < 1) - { - Log.Error("Historian.RequestTimeoutSeconds must be at least 1"); - valid = false; - } - else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds) - { - Log.Warning( - "Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path", - config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds); - } - - if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName) - && config.Historian.ServerName != "localhost") - Log.Warning( - "Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries", - config.Historian.ServerName, clusterNodes.Count); - - if (config.Historian.Port < 1 || config.Historian.Port > 65535) - { - Log.Error("Historian.Port must be between 1 and 65535"); - valid = false; - } - - if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName)) - { - Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled"); - valid = false; - } - - if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password)) - Log.Warning("Historian.Password is empty — authentication may fail"); - } - - // Authentication - Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}", - config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite); - - if (config.Authentication.Ldap.Enabled) - { - Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}", - config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, - config.Authentication.Ldap.BaseDN); - Log.Information( - "Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}", - config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup, - config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup, - config.Authentication.Ldap.AlarmAckGroup); - - if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn)) - Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail"); - } - - // Redundancy - if (config.OpcUa.ApplicationUri != null) - Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri); - - Log.Information( - "Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}", - config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, - config.Redundancy.ServiceLevelBase); - - if (config.Redundancy.ServerUris.Count > 0) - Log.Information("Redundancy.ServerUris=[{ServerUris}]", - string.Join(", ", config.Redundancy.ServerUris)); - - if (config.Redundancy.Enabled) - { - if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri)) - { - Log.Error( - "OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity"); - valid = false; - } - - if (config.Redundancy.ServerUris.Count < 2) - Log.Warning( - "Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers"); - - if (config.OpcUa.ApplicationUri != null && - !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri)) - Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", - config.OpcUa.ApplicationUri); - - var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true); - if (mode == RedundancySupport.None) - Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", - config.Redundancy.Mode); - } - - if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255) - { - Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255"); - valid = false; - } - - Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID"); - return valid; - } - - private static string SanitizeConnectionString(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - return "(empty)"; - try - { - var builder = new SqlConnectionStringBuilder(connectionString); - if (!string.IsNullOrEmpty(builder.Password)) - builder.Password = "********"; - return builder.ConnectionString; - } - catch - { - return "(unparseable)"; - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs deleted file mode 100644 index 5e81b80..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/DashboardConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Status dashboard configuration. (SVC-003, DASH-001) - /// - public class DashboardConfiguration - { - /// - /// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service. - /// - public bool Enabled { get; set; } = true; - - /// - /// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state. - /// - public int Port { get; set; } = 8081; - - /// - /// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot. - /// - public int RefreshIntervalSeconds { get; set; } = 10; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs deleted file mode 100644 index fb0b482..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Galaxy repository database configuration. (SVC-003, GR-005) - /// - public class GalaxyRepositoryConfiguration - { - /// - /// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata. - /// - public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;"; - - /// - /// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space - /// rebuild. - /// - public int ChangeDetectionIntervalSeconds { get; set; } = 30; - - /// - /// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog. - /// - public int CommandTimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model. - /// - public bool ExtendedAttributes { get; set; } = false; - - /// - /// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space. - /// Galaxy loads all deployed objects (default). LocalPlatform loads only - /// objects hosted by the platform deployed on this machine. - /// - public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy; - - /// - /// Gets or sets an explicit platform node name for filtering. - /// When , the local machine name (Environment.MachineName) is used. - /// - public string? PlatformName { get; set; } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs deleted file mode 100644 index 77fdb7c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyScope.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space. - /// - public enum GalaxyScope - { - /// - /// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior). - /// - Galaxy, - - /// - /// Load only objects hosted by the local platform and the structural areas needed to reach them. - /// - LocalPlatform - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs deleted file mode 100644 index 924ca71..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianConfiguration.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Wonderware Historian SDK configuration for OPC UA historical data access. - /// - public class HistorianConfiguration - { - /// - /// Gets or sets a value indicating whether OPC UA historical data access is enabled. - /// - public bool Enabled { get; set; } = false; - - /// - /// Gets or sets the single Historian server hostname used when - /// is empty. Preserved for backward compatibility with pre-cluster deployments. - /// - public string ServerName { get; set; } = "localhost"; - - /// - /// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list - /// supersedes : the data source attempts each node in order on - /// connect, falling through to the next on failure. A failed node is placed in cooldown - /// for before being re-eligible. - /// - public List ServerNames { get; set; } = new(); - - /// - /// Gets or sets the cooldown window, in seconds, that a historian node is skipped after - /// a connection failure. A value of zero retries the node on every request. Default 60s. - /// - public int FailureCooldownSeconds { get; set; } = 60; - - /// - /// Gets or sets a value indicating whether Windows Integrated Security is used. - /// When false, and are used instead. - /// - public bool IntegratedSecurity { get; set; } = true; - - /// - /// Gets or sets the username for Historian authentication when is false. - /// - public string? UserName { get; set; } - - /// - /// Gets or sets the password for Historian authentication when is false. - /// - public string? Password { get; set; } - - /// - /// Gets or sets the Historian server TCP port. - /// - public int Port { get; set; } = 32568; - - /// - /// Gets or sets the packet timeout in seconds for Historian SDK operations. - /// - public int CommandTimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets the maximum number of values returned per HistoryRead request. - /// - public int MaxValuesPerRead { get; set; } = 10000; - - /// - /// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async Historian - /// operations invoked from the OPC UA stack thread (HistoryReadRaw, HistoryReadProcessed, - /// HistoryReadAtTime, HistoryReadEvents). This is a backstop for the case where a - /// historian query hangs outside — e.g., a slow SDK - /// reconnect or mid-failover cluster node. Must be comfortably larger than - /// so normal operation is never affected. Default 60s. - /// - public int RequestTimeoutSeconds { get; set; } = 60; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs deleted file mode 100644 index be94cfd..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapConfiguration.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// LDAP authentication and group-to-role mapping settings. - /// - public class LdapConfiguration - { - /// - /// Gets or sets whether LDAP authentication is enabled. - /// When true, user credentials are validated against the configured LDAP server - /// and group membership determines OPC UA permissions. - /// - public bool Enabled { get; set; } = false; - - /// - /// Gets or sets the LDAP server hostname or IP address. - /// - public string Host { get; set; } = "localhost"; - - /// - /// Gets or sets the LDAP server port. - /// - public int Port { get; set; } = 3893; - - /// - /// Gets or sets the base DN for LDAP operations. - /// - public string BaseDN { get; set; } = "dc=lmxopcua,dc=local"; - - /// - /// Gets or sets the bind DN template. Use {username} as a placeholder. - /// - public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local"; - - /// - /// Gets or sets the service account DN used for LDAP searches (group lookups). - /// - public string ServiceAccountDn { get; set; } = ""; - - /// - /// Gets or sets the service account password. - /// - public string ServiceAccountPassword { get; set; } = ""; - - /// - /// Gets or sets the LDAP connection timeout in seconds. - /// - public int TimeoutSeconds { get; set; } = 5; - - /// - /// Gets or sets the LDAP group name that grants read-only access. - /// - public string ReadOnlyGroup { get; set; } = "ReadOnly"; - - /// - /// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes. - /// - public string WriteOperateGroup { get; set; } = "WriteOperate"; - - /// - /// Gets or sets the LDAP group name that grants write access for Tune attributes. - /// - public string WriteTuneGroup { get; set; } = "WriteTune"; - - /// - /// Gets or sets the LDAP group name that grants write access for Configure attributes. - /// - public string WriteConfigureGroup { get; set; } = "WriteConfigure"; - - /// - /// Gets or sets the LDAP group name that grants alarm acknowledgment access. - /// - public string AlarmAckGroup { get; set; } = "AlarmAck"; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs deleted file mode 100644 index 4e460f5..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/MxAccessConfiguration.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// MXAccess client configuration. (SVC-003, MXA-008, MXA-009) - /// - public class MxAccessConfiguration - { - /// - /// Gets or sets the client name registered with the MXAccess runtime for this bridge instance. - /// - public string ClientName { get; set; } = "LmxOpcUa"; - - /// - /// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node. - /// - public string? NodeName { get; set; } - - /// - /// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics. - /// - public string? GalaxyName { get; set; } - - /// - /// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete. - /// - public int ReadTimeoutSeconds { get; set; } = 5; - - /// - /// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime. - /// - public int WriteTimeoutSeconds { get; set; } = 5; - - /// - /// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async MxAccess - /// operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe - /// sync). This is a backstop for the case where an async path hangs outside the inner - /// / bounds — e.g., a - /// slow reconnect or a scheduler stall. Must be comfortably larger than the inner timeouts - /// so normal operation is never affected. Default 30s. - /// - public int RequestTimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime. - /// - public int MaxConcurrentOperations { get; set; } = 10; - - /// - /// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection. - /// - public int MonitorIntervalSeconds { get; set; } = 5; - - /// - /// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess - /// session. - /// - public bool AutoReconnect { get; set; } = true; - - /// - /// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data. - /// - public string? ProbeTag { get; set; } - - /// - /// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale. - /// - public int ProbeStaleThresholdSeconds { get; set; } = 60; - - /// - /// Gets or sets a value indicating whether the bridge advises <ObjectName>.ScanState for every - /// deployed $WinPlatform and $AppEngine, reporting per-host runtime state on the status - /// dashboard and proactively invalidating OPC UA variable quality when a host transitions to Stopped. - /// Enabled by default. Disable to return to legacy behavior where host runtime state is invisible and - /// MxAccess's per-tag bad-quality fan-out is the only stop signal. - /// - public bool RuntimeStatusProbesEnabled { get; set; } = true; - - /// - /// Gets or sets the maximum seconds to wait for the initial probe callback before marking a host as - /// Stopped. Only applies to the Unknown → Stopped transition. Because ScanState is delivered - /// on-change only, a stably Running host does not time out — no starvation check runs on Running - /// entries. Default 15s. - /// - public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs deleted file mode 100644 index e9516e6..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013) - /// - public class OpcUaConfiguration - { - /// - /// Gets or sets the IP address or hostname the OPC UA server binds to. - /// Defaults to 0.0.0.0 (all interfaces). Set to a specific IP or hostname to restrict listening. - /// - public string BindAddress { get; set; } = "0.0.0.0"; - - /// - /// Gets or sets the TCP port on which the OPC UA server listens for client sessions. - /// - public int Port { get; set; } = 4840; - - /// - /// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server. - /// - public string EndpointPath { get; set; } = "/LmxOpcUa"; - - /// - /// Gets or sets the server name presented to OPC UA clients and used in diagnostics. - /// - public string ServerName { get; set; } = "LmxOpcUa"; - - /// - /// Gets or sets the Galaxy name represented by the published OPC UA namespace. - /// - public string GalaxyName { get; set; } = "ZB"; - - /// - /// Gets or sets the explicit application URI for this server instance. - /// When , defaults to urn:{GalaxyName}:LmxOpcUa. - /// Must be set to a unique value per instance when redundancy is enabled. - /// - public string? ApplicationUri { get; set; } - - /// - /// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host. - /// - public int MaxSessions { get; set; } = 100; - - /// - /// Gets or sets the session timeout, in minutes, before idle client sessions are closed. - /// - public int SessionTimeoutMinutes { get; set; } = 30; - - /// - /// Gets or sets a value indicating whether alarm tracking is enabled. - /// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored. - /// - public bool AlarmTrackingEnabled { get; set; } = false; - - /// - /// Gets or sets the template-based alarm object filter. When - /// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only - /// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored. - /// - public AlarmFilterConfiguration AlarmFilter { get; set; } = new(); - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs deleted file mode 100644 index 74a79c6..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/RedundancyConfiguration.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Non-transparent redundancy settings that control how the server advertises itself - /// within a redundant pair and computes its dynamic ServiceLevel. - /// - public class RedundancyConfiguration - { - /// - /// Gets or sets whether redundancy is enabled. When (default), - /// the server reports RedundancySupport.None and ServiceLevel = 255. - /// - public bool Enabled { get; set; } = false; - - /// - /// Gets or sets the redundancy mode. Valid values: Warm, Hot. - /// - public string Mode { get; set; } = "Warm"; - - /// - /// Gets or sets the role of this instance. Valid values: Primary, Secondary. - /// The primary advertises a higher ServiceLevel than the secondary when both are healthy. - /// - public string Role { get; set; } = "Primary"; - - /// - /// Gets or sets the ApplicationUri values for all servers in the redundant set. - /// Must include this instance's own OpcUa.ApplicationUri. - /// - public List ServerUris { get; set; } = new(); - - /// - /// Gets or sets the base ServiceLevel when the server is fully healthy. - /// The secondary automatically receives ServiceLevelBase - 50. - /// Valid range: 1-255. - /// - public int ServiceLevelBase { get; set; } = 200; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs deleted file mode 100644 index 67f584b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Configuration -{ - /// - /// Transport security settings that control which OPC UA security profiles the server exposes and how client - /// certificates are handled. - /// - public class SecurityProfileConfiguration - { - /// - /// Gets or sets the list of security profile names to expose as server endpoints. - /// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt". - /// Defaults to ["None"] for backward compatibility. - /// - public List Profiles { get; set; } = new() { "None" }; - - /// - /// Gets or sets a value indicating whether the server automatically accepts client certificates - /// that are not in the trusted store. Should be in production. - /// - public bool AutoAcceptClientCertificates { get; set; } = true; - - /// - /// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected. - /// - public bool RejectSHA1Certificates { get; set; } = true; - - /// - /// Gets or sets the minimum RSA key size required for client certificates. - /// - public int MinimumCertificateKeySize { get; set; } = 2048; - - /// - /// Gets or sets an optional override for the PKI root directory. - /// When , defaults to %LOCALAPPDATA%\OPC Foundation\pki. - /// - public string? PkiRootPath { get; set; } - - /// - /// Gets or sets an optional override for the server certificate subject name. - /// When , defaults to CN={ServerName}, O=ZB MOM, DC=localhost. - /// - public string? CertificateSubject { get; set; } - - /// - /// Gets or sets the lifetime of the auto-generated server certificate in months. - /// Defaults to 60 months (5 years). - /// - public int CertificateLifetimeMonths { get; set; } = 60; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs deleted file mode 100644 index 3224f28..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/AlarmObjectFilter.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Compiles and applies wildcard template patterns against Galaxy objects to decide which - /// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB — - /// so it is fully unit-testable with synthetic hierarchies. - /// - /// - /// Matching rules: - /// - /// An object is included when any template name in its derivation chain matches - /// any configured pattern. - /// Matching is case-insensitive and ignores the Galaxy leading $ prefix on - /// both the chain entry and the user pattern, so TestMachine* matches the stored - /// $TestMachine. - /// Inclusion propagates to every descendant of a matched object (containment subtree). - /// Each object is evaluated once — overlapping matches never produce duplicate - /// inclusions (set semantics). - /// - /// Pattern syntax: literal text plus * wildcards (zero or more characters). - /// Other regex metacharacters in the raw pattern are escaped and treated literally. - /// - public class AlarmObjectFilter - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly List _patterns; - private readonly List _rawPatterns; - private readonly HashSet _matchedRawPatterns; - - /// - /// Initializes a new alarm object filter from the supplied configuration section. - /// - /// The alarm filter configuration whose - /// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns. - public AlarmObjectFilter(AlarmFilterConfiguration? config) - { - _patterns = new List(); - _rawPatterns = new List(); - _matchedRawPatterns = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (config?.ObjectFilters == null) - return; - - foreach (var entry in config.ObjectFilters) - { - if (string.IsNullOrWhiteSpace(entry)) - continue; - - foreach (var piece in entry.Split(',')) - { - var trimmed = piece.Trim(); - if (trimmed.Length == 0) - continue; - - try - { - var normalized = Normalize(trimmed); - var regex = GlobToRegex(normalized); - _patterns.Add(regex); - _rawPatterns.Add(trimmed); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed); - } - } - } - } - - /// - /// Gets a value indicating whether the filter has any compiled patterns. When , - /// callers should treat alarm tracking as unfiltered (current behavior preserved). - /// - public bool Enabled => _patterns.Count > 0; - - /// - /// Gets the number of compiled patterns the filter will evaluate against each object. - /// - public int PatternCount => _patterns.Count; - - /// - /// Gets the raw pattern strings that did not match any object in the most recent call to - /// . Useful for startup warnings about operator typos. - /// - public IReadOnlyList UnmatchedPatterns => - _rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList(); - - /// - /// Gets the raw pattern strings exactly as supplied by the operator after comma-splitting - /// and trimming. Surfaced on the status dashboard so operators can confirm the active filter. - /// - public IReadOnlyList RawPatterns => _rawPatterns; - - /// - /// Returns when any template name in matches any - /// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern - /// equal to * (which collapses to an empty-matching regex after normalization). - /// - /// The template derivation chain to test (own template first, ancestors after). - public bool MatchesTemplateChain(IReadOnlyList? chain) - { - if (chain == null || chain.Count == 0 || _patterns.Count == 0) - return false; - - for (var i = 0; i < _patterns.Count; i++) - { - var regex = _patterns[i]; - for (var j = 0; j < chain.Count; j++) - { - var entry = chain[j]; - if (string.IsNullOrEmpty(entry)) - continue; - if (regex.IsMatch(Normalize(entry))) - { - _matchedRawPatterns.Add(_rawPatterns[i]); - return true; - } - } - } - - return false; - } - - /// - /// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms - /// should be monitored, honoring both template matching and descendant propagation. Returns - /// when the filter is disabled so callers can skip the containment check - /// entirely. - /// - /// The full deployed Galaxy hierarchy, as returned by the repository service. - /// The set of included gobject IDs, or when filtering is disabled. - public HashSet? ResolveIncludedObjects(IReadOnlyList? hierarchy) - { - if (!Enabled) - return null; - - _matchedRawPatterns.Clear(); - var included = new HashSet(); - if (hierarchy == null || hierarchy.Count == 0) - return included; - - var byId = new Dictionary(hierarchy.Count); - foreach (var obj in hierarchy) - byId[obj.GobjectId] = obj; - - var childrenByParent = new Dictionary>(); - foreach (var obj in hierarchy) - { - var parentId = obj.ParentGobjectId; - if (parentId != 0 && !byId.ContainsKey(parentId)) - parentId = 0; // orphan → treat as root - if (!childrenByParent.TryGetValue(parentId, out var list)) - { - list = new List(); - childrenByParent[parentId] = list; - } - list.Add(obj.GobjectId); - } - - var roots = childrenByParent.TryGetValue(0, out var rootList) - ? rootList - : new List(); - - var visited = new HashSet(); - var queue = new Queue<(int Id, bool ParentIncluded)>(); - foreach (var rootId in roots) - queue.Enqueue((rootId, false)); - - while (queue.Count > 0) - { - var (id, parentIncluded) = queue.Dequeue(); - if (!visited.Add(id)) - continue; // cycle defense - - if (!byId.TryGetValue(id, out var obj)) - continue; - - var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain); - if (nodeIncluded) - included.Add(id); - - if (childrenByParent.TryGetValue(id, out var children)) - foreach (var childId in children) - queue.Enqueue((childId, nodeIncluded)); - } - - return included; - } - - private static Regex GlobToRegex(string normalized) - { - var segments = normalized.Split('*'); - var parts = segments.Select(Regex.Escape); - var body = string.Join(".*", parts); - return new Regex("^" + body + "$", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); - } - - private static string Normalize(string value) - { - var trimmed = value.Trim(); - if (trimmed.StartsWith("$", StringComparison.Ordinal)) - return trimmed.Substring(1); - return trimmed; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs deleted file mode 100644 index f0a9f44..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionState.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// MXAccess connection lifecycle states. (MXA-002) - /// - public enum ConnectionState - { - /// - /// No active session exists to the Galaxy runtime. - /// - Disconnected, - - /// - /// The bridge is opening a new MXAccess session to the runtime. - /// - Connecting, - - /// - /// The bridge has an active MXAccess session and can service reads, writes, and subscriptions. - /// - Connected, - - /// - /// The bridge is closing the current MXAccess session and draining runtime resources. - /// - Disconnecting, - - /// - /// The bridge detected a connection fault that requires operator attention or recovery logic. - /// - Error, - - /// - /// The bridge is attempting to restore service after a runtime communication failure. - /// - Reconnecting - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs deleted file mode 100644 index 2a1cb9f..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Event args for connection state transitions. (MXA-002) - /// - public class ConnectionStateChangedEventArgs : EventArgs - { - /// - /// Initializes a new instance of the class. - /// - /// The connection state being exited. - /// The connection state being entered. - /// Additional context about the transition, such as a connection fault or reconnect attempt. - public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "") - { - PreviousState = previous; - CurrentState = current; - Message = message ?? ""; - } - - /// - /// Gets the previous MXAccess connection state before the transition was raised. - /// - public ConnectionState PreviousState { get; } - - /// - /// Gets the new MXAccess connection state that the bridge moved into. - /// - public ConnectionState CurrentState { get; } - - /// - /// Gets an operator-facing message that explains why the connection state changed. - /// - public string Message { get; } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs deleted file mode 100644 index 7865778..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyAttributeInfo.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// DTO matching attributes.sql result columns. (GR-002) - /// - public class GalaxyAttributeInfo - { - /// - /// Gets or sets the Galaxy object identifier that owns the attribute. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object. - /// - public string TagName { get; set; } = ""; - - /// - /// Gets or sets the attribute name as defined on the Galaxy template or instance. - /// - public string AttributeName { get; set; } = ""; - - /// - /// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes. - /// - public string FullTagReference { get; set; } = ""; - - /// - /// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA. - /// - public int MxDataType { get; set; } - - /// - /// Gets or sets the human-readable Galaxy data type name returned by the repository query. - /// - public string DataTypeName { get; set; } = ""; - - /// - /// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node. - /// - public bool IsArray { get; set; } - - /// - /// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array. - /// - public int? ArrayDimension { get; set; } - - /// - /// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients. - /// - public string PrimitiveName { get; set; } = ""; - - /// - /// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation, - /// or runtime data. - /// - public string AttributeSource { get; set; } = ""; - - /// - /// Gets or sets the Galaxy security classification that determines OPC UA write access. - /// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly. - /// - public int SecurityClassification { get; set; } = 1; - - /// - /// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the - /// Wonderware Historian. - /// - public bool IsHistorized { get; set; } - - /// - /// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm. - /// - public bool IsAlarm { get; set; } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs deleted file mode 100644 index be5a538..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyObjectInfo.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// DTO matching hierarchy.sql result columns. (GR-001) - /// - public class GalaxyObjectInfo - { - /// - /// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree. - /// - public string TagName { get; set; } = ""; - - /// - /// Gets or sets the contained name shown for the object inside its parent area or object. - /// - public string ContainedName { get; set; } = ""; - - /// - /// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy. - /// - public string BrowseName { get; set; } = ""; - - /// - /// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship. - /// - public int ParentGobjectId { get; set; } - - /// - /// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object. - /// - public bool IsArea { get; set; } - - /// - /// Gets or sets the template derivation chain for this object. Index 0 is the object's own template; - /// subsequent entries walk up toward the most ancestral template before $Object. Populated by - /// the recursive CTE in hierarchy.sql on gobject.derived_from_gobject_id. Used by - /// to decide whether an object's alarms should be monitored. - /// - public List TemplateChain { get; set; } = new(); - - /// - /// Gets or sets the Galaxy template category id for this object. Category 1 is $WinPlatform, - /// 3 is $AppEngine, 13 is $Area, 10 is $UserDefined, and so on. Populated from - /// template_definition.category_id by hierarchy.sql and consumed by the runtime - /// status probe manager to identify hosts that should receive a ScanState probe. - /// - public int CategoryId { get; set; } - - /// - /// Gets or sets the Galaxy object id of this object's runtime host, populated from - /// gobject.hosted_by_gobject_id. Walk this chain upward to find the nearest - /// $WinPlatform or $AppEngine ancestor for subtree quality invalidation when - /// a runtime host is reported Stopped. Zero for root objects that have no host. - /// - public int HostedByGobjectId { get; set; } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs deleted file mode 100644 index 3e8fa57..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as - /// observed by the bridge via its ScanState probe. - /// - public enum GalaxyRuntimeState - { - /// - /// Probe advised but no callback received yet. Transitions to - /// on the first successful ScanState = true callback, or to - /// once the unknown-resolution timeout elapses. - /// - Unknown, - - /// - /// Last probe callback reported ScanState = true with a successful item status. - /// The host is on scan and executing. - /// - Running, - - /// - /// Last probe callback reported ScanState != true, or a failed item status, or - /// the initial probe never resolved before the unknown timeout elapsed. The host is - /// off scan or unreachable. - /// - Stopped - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs deleted file mode 100644 index f859fe9..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine) - /// as tracked by the GalaxyRuntimeProbeManager. Surfaced on the status dashboard and - /// consumed by HealthCheckService so operators can detect a stopped host before - /// downstream clients notice the stale data. - /// - public sealed class GalaxyRuntimeStatus - { - /// - /// Gets or sets the Galaxy tag_name of the host (e.g., DevPlatform or - /// DevAppEngine). - /// - public string ObjectName { get; set; } = ""; - - /// - /// Gets or sets the Galaxy gobject_id of the host. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the Galaxy template category name — $WinPlatform or - /// $AppEngine. Used by the dashboard to group hosts by kind. - /// - public string Kind { get; set; } = ""; - - /// - /// Gets or sets the current runtime state. - /// - public GalaxyRuntimeState State { get; set; } - - /// - /// Gets or sets the UTC timestamp of the most recent probe callback, whether it - /// reported success or failure. before the first callback. - /// - public DateTime? LastStateCallbackTime { get; set; } - - /// - /// Gets or sets the UTC timestamp of the most recent transition. - /// Backs the dashboard "Since" column. in the initial Unknown - /// state before any transition. - /// - public DateTime? LastStateChangeTime { get; set; } - - /// - /// Gets or sets the last ScanState value received from the probe, or - /// before the first update or when the last callback carried - /// a non-success item status (no value delivered). - /// - public bool? LastScanState { get; set; } - - /// - /// Gets or sets the detail message from the most recent failure callback, cleared on - /// the next successful ScanState = true delivery. - /// - public string? LastError { get; set; } - - /// - /// Gets or sets the cumulative number of callbacks where ScanState = true. - /// - public long GoodUpdateCount { get; set; } - - /// - /// Gets or sets the cumulative number of callbacks where ScanState != true - /// or the item status reported failure. - /// - public long FailureCount { get; set; } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs deleted file mode 100644 index 60440da..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IGalaxyRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Interface for Galaxy repository database queries. (GR-001 through GR-004) - /// - public interface IGalaxyRepository - { - /// - /// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree. - /// - /// A token that cancels the repository query. - /// A list of Galaxy objects ordered for address-space construction. - Task> GetHierarchyAsync(CancellationToken ct = default); - - /// - /// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy. - /// - /// A token that cancels the repository query. - /// A list of attribute definitions with MXAccess references and type metadata. - Task> GetAttributesAsync(CancellationToken ct = default); - - /// - /// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild. - /// - /// A token that cancels the repository query. - /// The latest deploy timestamp, or when it cannot be determined. - Task GetLastDeployTimeAsync(CancellationToken ct = default); - - /// - /// Verifies that the service can reach the Galaxy repository before it attempts to build the address space. - /// - /// A token that cancels the connectivity check. - /// when repository access succeeds; otherwise, . - Task TestConnectionAsync(CancellationToken ct = default); - - /// - /// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild. - /// - event Action? OnGalaxyChanged; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs deleted file mode 100644 index c5c89aa..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Abstraction over MXAccess COM client for tag read/write/subscribe operations. - /// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009) - /// - public interface IMxAccessClient : IDisposable - { - /// - /// Gets the current runtime connectivity state for the bridge. - /// - ConnectionState State { get; } - - /// - /// Gets the number of active runtime subscriptions currently being mirrored into OPC UA. - /// - int ActiveSubscriptionCount { get; } - - /// - /// Gets the number of reconnect cycles attempted since the client was created. - /// - int ReconnectCount { get; } - - /// - /// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic. - /// - event EventHandler? ConnectionStateChanged; - - /// - /// Occurs when a subscribed Galaxy attribute publishes a new runtime value. - /// - event Action? OnTagValueChanged; - - /// - /// Opens the MXAccess session required for runtime reads, writes, and subscriptions. - /// - /// A token that cancels the connection attempt. - Task ConnectAsync(CancellationToken ct = default); - - /// - /// Closes the MXAccess session and releases runtime resources. - /// - Task DisconnectAsync(); - - /// - /// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers. - /// - /// The fully qualified MXAccess reference for the target attribute. - /// The callback to invoke when the runtime publishes a new value for the attribute. - Task SubscribeAsync(string fullTagReference, Action callback); - - /// - /// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer. - /// - /// The fully qualified MXAccess reference for the target attribute. - Task UnsubscribeAsync(string fullTagReference); - - /// - /// Reads the current runtime value for a Galaxy attribute. - /// - /// The fully qualified MXAccess reference for the target attribute. - /// A token that cancels the read. - /// The value, timestamp, and quality returned by the runtime. - Task ReadAsync(string fullTagReference, CancellationToken ct = default); - - /// - /// Writes a new runtime value to a writable Galaxy attribute. - /// - /// The fully qualified MXAccess reference for the target attribute. - /// The value to write to the runtime. - /// A token that cancels the write. - /// when the write is accepted by the runtime; otherwise, . - Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default); - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs deleted file mode 100644 index e03ea54..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxProxy.cs +++ /dev/null @@ -1,99 +0,0 @@ -using ArchestrA.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Delegate matching LMXProxyServer.OnDataChange COM event signature. - /// - /// The runtime connection handle that raised the change. - /// The runtime item handle for the attribute that changed. - /// The new raw runtime value for the attribute. - /// The OPC DA quality code supplied by the runtime. - /// The timestamp object supplied by the runtime for the value. - /// The MXAccess status payload associated with the callback. - public delegate void MxDataChangeHandler( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] ItemStatus); - - /// - /// Delegate matching LMXProxyServer.OnWriteComplete COM event signature. - /// - /// The runtime connection handle that processed the write. - /// The runtime item handle that was written. - /// The MXAccess status payload describing the write outcome. - public delegate void MxWriteCompleteHandler( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] ItemStatus); - - /// - /// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001) - /// - public interface IMxProxy - { - /// - /// Registers the bridge as an MXAccess client with the runtime proxy. - /// - /// The client identity reported to the runtime for diagnostics and session tracking. - /// The runtime connection handle assigned to the client session. - int Register(string clientName); - - /// - /// Unregisters the bridge from the runtime proxy and releases the connection handle. - /// - /// The connection handle returned by . - void Unregister(int handle); - - /// - /// Adds a Galaxy attribute reference to the active runtime session. - /// - /// The runtime connection handle. - /// The fully qualified attribute reference to resolve. - /// The runtime item handle assigned to the attribute. - int AddItem(int handle, string address); - - /// - /// Removes a previously registered attribute from the runtime session. - /// - /// The runtime connection handle. - /// The item handle returned by . - void RemoveItem(int handle, int itemHandle); - - /// - /// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge. - /// - /// The runtime connection handle. - /// The item handle to monitor. - void AdviseSupervisory(int handle, int itemHandle); - - /// - /// Stops supervisory updates for an attribute. - /// - /// The runtime connection handle. - /// The item handle to stop monitoring. - void UnAdviseSupervisory(int handle, int itemHandle); - - /// - /// Writes a new value to a runtime attribute through the COM proxy. - /// - /// The runtime connection handle. - /// The item handle to write. - /// The new value to push into the runtime. - /// The Wonderware security classification applied to the write. - void Write(int handle, int itemHandle, object value, int securityClassification); - - /// - /// Occurs when the runtime pushes a data-change callback for a subscribed attribute. - /// - event MxDataChangeHandler? OnDataChange; - - /// - /// Occurs when the runtime acknowledges completion of a write request. - /// - event MxWriteCompleteHandler? OnWriteComplete; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs deleted file mode 100644 index 7fead8c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/IUserAuthenticationProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, - /// etc.). - /// - public interface IUserAuthenticationProvider - { - /// - /// Validates a username/password combination. - /// - bool ValidateCredentials(string username, string password); - } - - /// - /// Extended interface for providers that can resolve application-level roles for authenticated users. - /// When the auth provider implements this interface, OnImpersonateUser uses the returned roles - /// to control write and alarm-ack permissions. - /// - public interface IRoleProvider - { - /// - /// Returns the set of application-level roles granted to the user. - /// - IReadOnlyList GetUserRoles(string username); - } - - /// - /// Well-known application-level role names used for permission enforcement. - /// - public static class AppRoles - { - public const string ReadOnly = "ReadOnly"; - public const string WriteOperate = "WriteOperate"; - public const string WriteTune = "WriteTune"; - public const string WriteConfigure = "WriteConfigure"; - public const string AlarmAck = "AlarmAck"; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs deleted file mode 100644 index 1b25c75..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LdapAuthenticationProvider.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.DirectoryServices.Protocols; -using System.Net; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Validates credentials via LDAP bind and resolves group membership to application roles. - /// - public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly LdapConfiguration _config; - private readonly Dictionary _groupToRole; - - public LdapAuthenticationProvider(LdapConfiguration config) - { - _config = config; - _groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { config.ReadOnlyGroup, AppRoles.ReadOnly }, - { config.WriteOperateGroup, AppRoles.WriteOperate }, - { config.WriteTuneGroup, AppRoles.WriteTune }, - { config.WriteConfigureGroup, AppRoles.WriteConfigure }, - { config.AlarmAckGroup, AppRoles.AlarmAck } - }; - } - - public IReadOnlyList GetUserRoles(string username) - { - try - { - using (var connection = CreateConnection()) - { - // Bind with service account to search - connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword)); - - var request = new SearchRequest( - _config.BaseDN, - $"(cn={EscapeLdapFilter(username)})", - SearchScope.Subtree, - "memberOf"); - - var response = (SearchResponse)connection.SendRequest(request); - - if (response.Entries.Count == 0) - { - Log.Warning("LDAP search returned no entries for {Username}", username); - return new[] { AppRoles.ReadOnly }; // safe fallback - } - - var entry = response.Entries[0]; - var memberOf = entry.Attributes["memberOf"]; - if (memberOf == null || memberOf.Count == 0) - { - Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username); - return new[] { AppRoles.ReadOnly }; - } - - var roles = new List(); - for (var i = 0; i < memberOf.Count; i++) - { - var dn = memberOf[i]?.ToString() ?? ""; - // Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...") - var groupName = ExtractGroupName(dn); - if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role); - } - - if (roles.Count == 0) - { - Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username); - roles.Add(AppRoles.ReadOnly); - } - - Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles)); - return roles; - } - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username); - return new[] { AppRoles.ReadOnly }; - } - } - - public bool ValidateCredentials(string username, string password) - { - try - { - var bindDn = _config.BindDnTemplate.Replace("{username}", username); - using (var connection = CreateConnection()) - { - connection.Bind(new NetworkCredential(bindDn, password)); - } - - Log.Debug("LDAP bind succeeded for {Username}", username); - return true; - } - catch (LdapException ex) - { - Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message); - return false; - } - catch (Exception ex) - { - Log.Warning(ex, "LDAP error during credential validation for {Username}", username); - return false; - } - } - - private LdapConnection CreateConnection() - { - var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port); - var connection = new LdapConnection(identifier) - { - AuthType = AuthType.Basic, - Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds) - }; - connection.SessionOptions.ProtocolVersion = 3; - return connection; - } - - private static string? ExtractGroupName(string dn) - { - // Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..." - if (string.IsNullOrEmpty(dn)) return null; - var parts = dn.Split(','); - if (parts.Length == 0) return null; - var first = parts[0].Trim(); - var eqIdx = first.IndexOf('='); - return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null; - } - - private static string EscapeLdapFilter(string input) - { - return input - .Replace("\\", "\\5c") - .Replace("*", "\\2a") - .Replace("(", "\\28") - .Replace(")", "\\29") - .Replace("\0", "\\00"); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs deleted file mode 100644 index 1b239b0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/LmxRoleIds.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Stable identifiers for custom OPC UA roles mapped from LDAP groups. - /// The namespace URI is registered in the server namespace table at startup, - /// and the string identifiers are resolved to runtime NodeIds before use. - /// - public static class LmxRoleIds - { - public const string NamespaceUri = "urn:zbmom:lmxopcua:roles"; - - public const string ReadOnly = "Role.ReadOnly"; - public const string WriteOperate = "Role.WriteOperate"; - public const string WriteTune = "Role.WriteTune"; - public const string WriteConfigure = "Role.WriteConfigure"; - public const string AlarmAck = "Role.AlarmAck"; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs deleted file mode 100644 index 3e59b84..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005) - /// See gr/data_type_mapping.md for full mapping table. - /// - public static class MxDataTypeMapper - { - /// - /// Maps mx_data_type to OPC UA DataType NodeId numeric identifier. - /// Unknown types default to String (i=12). - /// - /// The Galaxy MX data type code. - /// The OPC UA built-in data type node identifier. - public static uint MapToOpcUaDataType(int mxDataType) - { - return mxDataType switch - { - 1 => 1, // Boolean → i=1 - 2 => 6, // Integer → Int32 i=6 - 3 => 10, // Float → Float i=10 - 4 => 11, // Double → Double i=11 - 5 => 12, // String → String i=12 - 6 => 13, // Time → DateTime i=13 - 7 => 11, // ElapsedTime → Double i=11 (seconds) - 8 => 12, // Reference → String i=12 - 13 => 6, // Enumeration → Int32 i=6 - 14 => 12, // Custom → String i=12 - 15 => 21, // InternationalizedString → LocalizedText i=21 - 16 => 12, // Custom → String i=12 - _ => 12 // Unknown → String i=12 - }; - } - - /// - /// Maps mx_data_type to the corresponding CLR type. - /// - /// The Galaxy MX data type code. - /// The CLR type used to represent runtime values for the MX type. - public static Type MapToClrType(int mxDataType) - { - return mxDataType switch - { - 1 => typeof(bool), - 2 => typeof(int), - 3 => typeof(float), - 4 => typeof(double), - 5 => typeof(string), - 6 => typeof(DateTime), - 7 => typeof(double), // ElapsedTime as seconds - 8 => typeof(string), // Reference as string - 13 => typeof(int), // Enum backing integer - 14 => typeof(string), - 15 => typeof(string), // LocalizedText stored as string - 16 => typeof(string), - _ => typeof(string) - }; - } - - /// - /// Returns the OPC UA type name for a given mx_data_type. - /// - /// The Galaxy MX data type code. - /// The OPC UA type name used in diagnostics. - public static string GetOpcUaTypeName(int mxDataType) - { - return mxDataType switch - { - 1 => "Boolean", - 2 => "Int32", - 3 => "Float", - 4 => "Double", - 5 => "String", - 6 => "DateTime", - 7 => "Double", - 8 => "String", - 13 => "Int32", - 14 => "String", - 15 => "LocalizedText", - 16 => "String", - _ => "String" - }; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs deleted file mode 100644 index 5222ac2..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxErrorCodes.cs +++ /dev/null @@ -1,76 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009) - /// - public static class MxErrorCodes - { - /// - /// The requested Galaxy attribute reference does not resolve in the runtime. - /// - public const int MX_E_InvalidReference = 1008; - - /// - /// The supplied value does not match the attribute's configured data type. - /// - public const int MX_E_WrongDataType = 1012; - - /// - /// The target attribute cannot be written because it is read-only or protected. - /// - public const int MX_E_NotWritable = 1013; - - /// - /// The runtime did not complete the operation within the configured timeout. - /// - public const int MX_E_RequestTimedOut = 1014; - - /// - /// Communication with the MXAccess runtime failed during the operation. - /// - public const int MX_E_CommFailure = 1015; - - /// - /// The operation was attempted without an active MXAccess session. - /// - public const int MX_E_NotConnected = 1016; - - /// - /// Converts a numeric MXAccess error code into an operator-facing message. - /// - /// The MXAccess error code returned by the runtime. - /// A human-readable description of the runtime failure. - public static string GetMessage(int errorCode) - { - return errorCode switch - { - 1008 => "Invalid reference: the tag address does not exist or is malformed", - 1012 => "Wrong data type: the value type does not match the attribute's expected type", - 1013 => "Not writable: the attribute is read-only or locked", - 1014 => "Request timed out: the operation did not complete within the allowed time", - 1015 => "Communication failure: lost connection to the runtime", - 1016 => "Not connected: no active connection to the Galaxy runtime", - _ => $"Unknown MXAccess error code: {errorCode}" - }; - } - - /// - /// Maps an MXAccess error code to the OPC quality state that should be exposed to clients. - /// - /// The MXAccess error code returned by the runtime. - /// The quality classification that best represents the runtime failure. - public static Quality MapToQuality(int errorCode) - { - return errorCode switch - { - 1008 => Quality.BadConfigError, - 1012 => Quality.BadConfigError, - 1013 => Quality.BadOutOfService, - 1014 => Quality.BadCommFailure, - 1015 => Quality.BadCommFailure, - 1016 => Quality.BadNotConnected, - _ => Quality.Bad - }; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs deleted file mode 100644 index 4426c31..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Maps a deployed Galaxy platform to the hostname where it executes. - /// - public class PlatformInfo - { - /// - /// Gets or sets the gobject_id of the platform object in the Galaxy repository. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the hostname (node_name) where the platform is deployed. - /// - public string NodeName { get; set; } = ""; - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs deleted file mode 100644 index 7d62704..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Quality.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005) - /// - public enum Quality : byte - { - // Bad family (0-63) - /// - /// No valid process value is available. - /// - Bad = 0, - - /// - /// The value is invalid because the Galaxy attribute definition or mapping is wrong. - /// - BadConfigError = 4, - - /// - /// The bridge is not currently connected to the Galaxy runtime. - /// - BadNotConnected = 8, - - /// - /// The runtime device or adapter failed while obtaining the value. - /// - BadDeviceFailure = 12, - - /// - /// The underlying field source reported a bad sensor condition. - /// - BadSensorFailure = 16, - - /// - /// Communication with the runtime failed while retrieving the value. - /// - BadCommFailure = 20, - - /// - /// The attribute is intentionally unavailable for service, such as a locked or unwritable value. - /// - BadOutOfService = 24, - - /// - /// The bridge is still waiting for the first usable value after startup or resubscription. - /// - BadWaitingForInitialData = 32, - - // Uncertain family (64-191) - /// - /// A value is available, but it should be treated cautiously. - /// - Uncertain = 64, - - /// - /// The last usable value is being repeated because a newer one is unavailable. - /// - UncertainLastUsable = 68, - - /// - /// The sensor or source is providing a value with reduced accuracy. - /// - UncertainSensorNotAccurate = 80, - - /// - /// The value exceeds its engineered limits. - /// - UncertainEuExceeded = 84, - - /// - /// The source is operating in a degraded or subnormal state. - /// - UncertainSubNormal = 88, - - // Good family (192+) - /// - /// The value is current and suitable for normal client use. - /// - Good = 192, - - /// - /// The value is good but currently overridden locally rather than flowing from the live source. - /// - GoodLocalOverride = 216 - } - - /// - /// Helper methods for reasoning about OPC quality families used by the bridge. - /// - public static class QualityExtensions - { - /// - /// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients. - /// - /// The quality code to inspect. - /// when the value is in the good quality range; otherwise, . - public static bool IsGood(this Quality q) - { - return (byte)q >= 192; - } - - /// - /// Determines whether the quality represents an uncertain runtime value that should be treated cautiously. - /// - /// The quality code to inspect. - /// when the value is in the uncertain range; otherwise, . - public static bool IsUncertain(this Quality q) - { - return (byte)q >= 64 && (byte)q < 192; - } - - /// - /// Determines whether the quality represents a bad runtime value that should not be used as valid process data. - /// - /// The quality code to inspect. - /// when the value is in the bad range; otherwise, . - public static bool IsBad(this Quality q) - { - return (byte)q < 64; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs deleted file mode 100644 index 45c5e1c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/QualityMapper.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005) - /// - public static class QualityMapper - { - /// - /// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality. - /// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad. - /// - /// The raw MXAccess quality integer. - /// The mapped bridge quality value. - public static Quality MapFromMxAccessQuality(int mxQuality) - { - var b = (byte)(mxQuality & 0xFF); - - // Try exact match first - if (Enum.IsDefined(typeof(Quality), b)) - return (Quality)b; - - // Fall back to category - if (b >= 192) return Quality.Good; - if (b >= 64) return Quality.Uncertain; - return Quality.Bad; - } - - /// - /// Maps domain Quality to OPC UA StatusCode uint32. - /// - /// The bridge quality value. - /// The OPC UA status code represented as a 32-bit unsigned integer. - public static uint MapToOpcUaStatusCode(Quality quality) - { - return quality switch - { - Quality.Good => 0x00000000u, // Good - Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride - Quality.Uncertain => 0x40000000u, // Uncertain - Quality.UncertainLastUsable => 0x40900000u, - Quality.UncertainSensorNotAccurate => 0x40930000u, - Quality.UncertainEuExceeded => 0x40940000u, - Quality.UncertainSubNormal => 0x40950000u, - Quality.Bad => 0x80000000u, // Bad - Quality.BadConfigError => 0x80890000u, - Quality.BadNotConnected => 0x808A0000u, - Quality.BadDeviceFailure => 0x808B0000u, - Quality.BadSensorFailure => 0x808C0000u, - Quality.BadCommFailure => 0x80050000u, - Quality.BadOutOfService => 0x808D0000u, - Quality.BadWaitingForInitialData => 0x80320000u, - _ => quality.IsGood() ? 0x00000000u : - quality.IsUncertain() ? 0x40000000u : - 0x80000000u - }; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs deleted file mode 100644 index b5a51c1..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Maps Galaxy security classification values to OPC UA write access decisions. - /// See gr/data_type_mapping.md for the full mapping table. - /// - public static class SecurityClassificationMapper - { - /// - /// Determines whether an attribute with the given security classification should allow writes. - /// - /// The Galaxy security classification value. - /// - /// for FreeAccess (0), Operate (1), Tune (4), Configure (5); - /// for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6). - /// - public static bool IsWritable(int securityClassification) - { - switch (securityClassification) - { - case 2: // SecuredWrite - case 3: // VerifiedWrite - case 6: // ViewOnly - return false; - default: - return true; - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs deleted file mode 100644 index 79ac529..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Domain/Vtq.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Domain -{ - /// - /// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007) - /// - public readonly struct Vtq : IEquatable - { - /// - /// Gets the runtime value returned for the Galaxy attribute. - /// - public object? Value { get; } - - /// - /// Gets the timestamp associated with the runtime value. - /// - public DateTime Timestamp { get; } - - /// - /// Gets the quality classification that tells OPC UA clients whether the value is usable. - /// - public Quality Quality { get; } - - /// - /// Initializes a new instance of the struct for a Galaxy attribute value. - /// - /// The runtime value returned by MXAccess. - /// The timestamp assigned to the runtime value. - /// The quality classification for the runtime value. - public Vtq(object? value, DateTime timestamp, Quality quality) - { - Value = value; - Timestamp = timestamp; - Quality = quality; - } - - /// - /// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value. - /// - /// The runtime value to wrap. - /// A VTQ carrying the provided value with the current UTC timestamp and good quality. - public static Vtq Good(object? value) - { - return new Vtq(value, DateTime.UtcNow, Quality.Good); - } - - /// - /// Creates a bad-quality VTQ snapshot when no usable runtime value is available. - /// - /// The specific bad quality reason to expose to clients. - /// A VTQ with no value, the current UTC timestamp, and the requested bad quality. - public static Vtq Bad(Quality quality = Quality.Bad) - { - return new Vtq(null, DateTime.UtcNow, quality); - } - - /// - /// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously. - /// - /// The runtime value to wrap. - /// A VTQ carrying the provided value with the current UTC timestamp and uncertain quality. - public static Vtq Uncertain(object? value) - { - return new Vtq(value, DateTime.UtcNow, Quality.Uncertain); - } - - /// - /// Compares two VTQ snapshots for exact value, timestamp, and quality equality. - /// - /// The other VTQ snapshot to compare. - /// when all fields match; otherwise, . - public bool Equals(Vtq other) - { - return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality; - } - - /// - public override bool Equals(object? obj) - { - return obj is Vtq other && Equals(other); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(Value, Timestamp, Quality); - } - - /// - public override string ToString() - { - return $"Vtq({Value}, {Timestamp:O}, {Quality})"; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml b/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml deleted file mode 100644 index e70d0c2..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - ArchestrA.MxAccess - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd b/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd deleted file mode 100644 index f2dbece..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/FodyWeavers.xsd +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - Obsolete, use UnmanagedWinX86Assemblies instead - - - - - A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks. - - - - - Obsolete, use UnmanagedWinX64Assemblies instead. - - - - - A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks. - - - - - A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks. - - - - - The order of preloaded assemblies, delimited with line breaks. - - - - - - This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. - - - - - Controls if .pdbs for reference assemblies are also embedded. - - - - - Controls if runtime assemblies are also embedded. - - - - - Controls whether the runtime assemblies are embedded with their full path or only with their assembly name. - - - - - Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. - - - - - As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. - - - - - The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events. - - - - - Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. - - - - - Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - Obsolete, use UnmanagedWinX86Assemblies instead - - - - - A list of unmanaged X86 (32 bit) assembly names to include, delimited with |. - - - - - Obsolete, use UnmanagedWinX64Assemblies instead - - - - - A list of unmanaged X64 (64 bit) assembly names to include, delimited with |. - - - - - A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |. - - - - - The order of preloaded assemblies, delimited with |. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs deleted file mode 100644 index f9e53ab..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository -{ - /// - /// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004) - /// - public class ChangeDetectionService : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly int _intervalSeconds; - - private readonly IGalaxyRepository _repository; - private CancellationTokenSource? _cts; - private Task? _pollTask; - - /// - /// Initializes a new change detector for Galaxy deploy timestamps. - /// - /// The repository used to query the latest deploy timestamp. - /// The polling interval, in seconds, between deploy checks. - /// An optional deploy timestamp already known at service startup. - public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, - DateTime? initialDeployTime = null) - { - _repository = repository; - _intervalSeconds = intervalSeconds; - LastKnownDeployTime = initialDeployTime; - } - - /// - /// Gets the last deploy timestamp observed by the polling loop. - /// - public DateTime? LastKnownDeployTime { get; private set; } - - /// - /// Stops the polling loop and disposes the underlying cancellation resources. - /// - public void Dispose() - { - Stop(); - _cts?.Dispose(); - } - - /// - /// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt. - /// - public event Action? OnGalaxyChanged; - - /// - /// Starts the background polling loop that watches for Galaxy deploy changes. - /// - public void Start() - { - if (_cts != null) - Stop(); - - _cts = new CancellationTokenSource(); - _pollTask = Task.Run(() => PollLoopAsync(_cts.Token)); - Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds); - } - - /// - /// Stops the background polling loop. - /// - public void Stop() - { - _cts?.Cancel(); - try { _pollTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ } - _pollTask = null; - Log.Information("Change detection stopped"); - } - - private async Task PollLoopAsync(CancellationToken ct) - { - // If no initial deploy time was provided, first poll triggers unconditionally - var firstPoll = LastKnownDeployTime == null; - - while (!ct.IsCancellationRequested) - { - try - { - var deployTime = await _repository.GetLastDeployTimeAsync(ct); - - if (firstPoll) - { - firstPoll = false; - LastKnownDeployTime = deployTime; - Log.Information("Initial deploy time: {DeployTime}", deployTime); - OnGalaxyChanged?.Invoke(); - } - else if (deployTime != LastKnownDeployTime) - { - Log.Information("Galaxy deployment change detected: {Previous} → {Current}", - LastKnownDeployTime, deployTime); - LastKnownDeployTime = deployTime; - OnGalaxyChanged?.Invoke(); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Log.Warning(ex, "Change detection poll failed, will retry next interval"); - } - - try - { - await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), ct); - } - catch (OperationCanceledException) - { - break; - } - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs deleted file mode 100644 index 4ca0e15..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs +++ /dev/null @@ -1,529 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.SqlClient; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository -{ - /// - /// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007) - /// - public class GalaxyRepositoryService : IGalaxyRepository - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly GalaxyRepositoryConfiguration _config; - - /// - /// When filtering is active, caches the set of - /// gobject_ids that passed the hierarchy filter so can apply the same scope. - /// Populated by and consumed by . - /// - private HashSet? _scopeFilteredGobjectIds; - - /// - /// Initializes a new repository service that reads Galaxy metadata from the configured SQL database. - /// - /// The repository connection, timeout, and attribute-selection settings. - public GalaxyRepositoryService(GalaxyRepositoryConfiguration config) - { - _config = config; - } - - /// - /// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild. - /// - public event Action? OnGalaxyChanged; - - /// - /// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree. - /// - /// A token that cancels the database query. - /// The deployed Galaxy objects that should appear in the namespace. - public async Task> GetHierarchyAsync(CancellationToken ct = default) - { - var results = new List(); - - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - var templateChainRaw = reader.IsDBNull(8) ? "" : reader.GetString(8); - var templateChain = string.IsNullOrEmpty(templateChainRaw) - ? new List() - : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => s.Length > 0) - .ToList(); - - results.Add(new GalaxyObjectInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - ContainedName = reader.IsDBNull(2) ? "" : 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 - }); - } - - if (results.Count == 0) - Log.Warning("GetHierarchyAsync returned zero rows"); - else - Log.Information("GetHierarchyAsync returned {Count} objects", results.Count); - - if (_config.Scope == GalaxyScope.LocalPlatform) - { - var platforms = await GetPlatformsAsync(ct); - var platformName = string.IsNullOrWhiteSpace(_config.PlatformName) - ? Environment.MachineName - : _config.PlatformName; - var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName); - _scopeFilteredGobjectIds = gobjectIds; - return filtered; - } - - _scopeFilteredGobjectIds = null; - return results; - } - - /// - /// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes. - /// - /// A token that cancels the database query. - /// The attribute rows required to build runtime tag mappings and variable metadata. - public async Task> GetAttributesAsync(CancellationToken ct = default) - { - var results = new List(); - var extended = _config.ExtendedAttributes; - var sql = extended ? ExtendedAttributesSql : AttributesSql; - - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader)); - - Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count, - extended); - - if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null) - return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds); - - return results; - } - - /// - /// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale. - /// - /// A token that cancels the database query. - /// The most recent deploy timestamp, or when none is available. - public async Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - var result = await cmd.ExecuteScalarAsync(ct); - - return result is DateTime dt ? dt : null; - } - - /// - /// Executes a lightweight query to confirm that the repository database is reachable. - /// - /// A token that cancels the connectivity check. - /// when the query succeeds; otherwise, . - public async Task TestConnectionAsync(CancellationToken ct = default) - { - try - { - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(TestConnectionSql, conn) - { CommandTimeout = _config.CommandTimeoutSeconds }; - await cmd.ExecuteScalarAsync(ct); - - Log.Information("Galaxy repository database connection successful"); - return true; - } - catch (Exception ex) - { - Log.Warning(ex, "Galaxy repository database connection failed"); - return false; - } - } - - /// - /// Queries the platform table for deployed platform-to-hostname mappings used by - /// filtering. - /// - private async Task> GetPlatformsAsync(CancellationToken ct = default) - { - var results = new List(); - - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - results.Add(new PlatformInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1) - }); - } - - Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count); - return results; - } - - /// - /// Reads a row from the standard attributes query (12 columns). - /// Columns: 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 - /// - private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader) - { - return new GalaxyAttributeInfo - { - 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) ? "" : reader.GetString(5), - IsArray = Convert.ToBoolean(reader.GetValue(6)), - ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)), - SecurityClassification = Convert.ToInt32(reader.GetValue(9)), - IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1 - }; - } - - /// - /// Reads a row from the extended attributes query (14 columns). - /// Columns: gobject_id, tag_name, primitive_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, attribute_source - /// - private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader) - { - return new GalaxyAttributeInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2), - AttributeName = reader.GetString(3), - FullTagReference = reader.GetString(4), - MxDataType = Convert.ToInt32(reader.GetValue(5)), - DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6), - IsArray = Convert.ToBoolean(reader.GetValue(7)), - ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)), - SecurityClassification = Convert.ToInt32(reader.GetValue(10)), - IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1, - AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13) - }; - } - - /// - /// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy. - /// - public void RaiseGalaxyChanged() - { - OnGalaxyChanged?.Invoke(); - } - - #region SQL Queries (GR-006: const string, no dynamic SQL) - - 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"; - - private const string ExtendedAttributesSql = @" -;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 -), -ranked_dynamic AS ( - 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) -) -SELECT - gobject_id, - tag_name, - primitive_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, - attribute_source -FROM ( - SELECT - g.gobject_id, - g.tag_name, - pi.primitive_name, - ad.attribute_name, - CASE WHEN pi.primitive_name = '' - THEN g.tag_name + '.' + ad.attribute_name - ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name - END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END - AS full_tag_reference, - ad.mx_data_type, - dt.description AS data_type_name, - ad.is_array, - CASE WHEN ad.is_array = 1 - THEN CONVERT(int, CONVERT(varbinary(2), - SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2)) - ELSE NULL - END AS array_dimension, - ad.mx_attribute_category, - ad.security_classification, - CAST(0 AS int) AS is_historized, - CAST(0 AS int) AS is_alarm, - 'primitive' AS attribute_source - FROM gobject g - INNER JOIN instance i - ON i.gobject_id = g.gobject_id - INNER JOIN template_definition td - ON td.template_definition_id = g.template_definition_id - AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}' - INNER JOIN package p - ON p.package_id = g.deployed_package_id - INNER JOIN primitive_instance pi - ON pi.package_id = p.package_id - AND pi.property_bitmask & 0x10 <> 0x10 - INNER JOIN attribute_definition ad - ON ad.primitive_definition_id = pi.primitive_definition_id - AND ad.attribute_name NOT LIKE '[_]%' - AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) - LEFT JOIN data_type dt - ON dt.mx_data_type = ad.mx_data_type - 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 - - UNION ALL - - SELECT - gobject_id, - tag_name, - '' AS primitive_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, - 'dynamic' AS attribute_source - FROM ranked_dynamic - WHERE rn = 1 -) all_attributes -ORDER BY tag_name, primitive_name, attribute_name"; - - private const string PlatformLookupSql = @" -SELECT p.platform_gobject_id, p.node_name -FROM platform p -INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id -WHERE g.is_template = 0 AND g.deployed_package_id <> 0"; - - private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy"; - - private const string TestConnectionSql = "SELECT 1"; - - #endregion - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs deleted file mode 100644 index 62fa1f1..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository -{ - /// - /// POCO for dashboard: Galaxy repository status info. (DASH-009) - /// - public class GalaxyRepositoryStats - { - /// - /// Gets or sets the Galaxy name currently being represented by the bridge. - /// - public string GalaxyName { get; set; } = ""; - - /// - /// Gets or sets a value indicating whether the Galaxy repository database is reachable. - /// - public bool DbConnected { get; set; } - - /// - /// Gets or sets the latest deploy timestamp read from the Galaxy repository. - /// - public DateTime? LastDeployTime { get; set; } - - /// - /// Gets or sets the number of Galaxy objects currently published into the OPC UA address space. - /// - public int ObjectCount { get; set; } - - /// - /// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space. - /// - public int AttributeCount { get; set; } - - /// - /// Gets or sets the UTC time when the address space was last rebuilt from repository data. - /// - public DateTime? LastRebuildTime { get; set; } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs deleted file mode 100644 index 4d7ab01..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository -{ - /// - /// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform - /// and the structural areas needed to keep the browse tree connected. - /// - public static class PlatformScopeFilter - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter)); - - private const int CategoryWinPlatform = 1; - private const int CategoryAppEngine = 3; - - /// - /// Filters the hierarchy to objects hosted by the platform whose node_name matches - /// , plus ancestor areas that keep the tree connected. - /// - /// The full Galaxy object hierarchy. - /// Deployed platform-to-hostname mappings from the platform table. - /// The target hostname to match (case-insensitive). - /// - /// The filtered hierarchy and the set of included gobject_ids (for attribute filtering). - /// When no matching platform is found, returns an empty list and empty set. - /// - public static (List Hierarchy, HashSet GobjectIds) Filter( - List hierarchy, - List platforms, - string platformName) - { - // Find the platform gobject_id that matches the target hostname. - var matchingPlatform = platforms.FirstOrDefault( - p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase)); - - if (matchingPlatform == null) - { - Log.Warning( - "Scope filter found no deployed platform matching node name '{PlatformName}'; " + - "available platforms: [{Available}]", - platformName, - string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})"))); - return (new List(), new HashSet()); - } - - var platformGobjectId = matchingPlatform.GobjectId; - Log.Information( - "Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})", - platformName, platformGobjectId); - - // Build a lookup for the hierarchy by gobject_id. - var byId = hierarchy.ToDictionary(o => o.GobjectId); - - // Step 1: Collect all host gobject_ids under this platform. - // Walk outward from the platform to find AppEngines (and any deeper hosting objects). - var hostIds = new HashSet { platformGobjectId }; - bool changed; - do - { - changed = false; - foreach (var obj in hierarchy) - { - if (hostIds.Contains(obj.GobjectId)) - continue; - if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId) - && (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform)) - { - hostIds.Add(obj.GobjectId); - changed = true; - } - } - } while (changed); - - // Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves. - var includedIds = new HashSet(hostIds); - foreach (var obj in hierarchy) - { - if (includedIds.Contains(obj.GobjectId)) - continue; - if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)) - includedIds.Add(obj.GobjectId); - } - - // Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected. - var toWalk = new Queue(includedIds); - while (toWalk.Count > 0) - { - var id = toWalk.Dequeue(); - if (!byId.TryGetValue(id, out var obj)) - continue; - var parentId = obj.ParentGobjectId; - if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId)) - toWalk.Enqueue(parentId); - } - - // Step 4: Return the filtered hierarchy preserving original order. - var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList(); - - Log.Information( - "Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'", - filtered.Count, hierarchy.Count, platformName); - - return (filtered, includedIds); - } - - /// - /// Filters attributes to retain only those belonging to objects in the given set. - /// - public static List FilterAttributes( - List attributes, - HashSet gobjectIds) - { - var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList(); - Log.Information( - "Scope filter retained {FilteredCount} of {TotalCount} attributes", - filtered.Count, attributes.Count); - return filtered; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs deleted file mode 100644 index 0405917..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianAggregateMap.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Opc.Ua; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// Maps OPC UA aggregate NodeIds to the Wonderware Historian AnalogSummary column names - /// consumed by the historian plugin. Kept in Host so HistoryReadProcessed can validate - /// aggregate support without requiring the plugin to be loaded. - /// - public static class HistorianAggregateMap - { - public static string? MapAggregateToColumn(NodeId aggregateId) - { - if (aggregateId == ObjectIds.AggregateFunction_Average) - return "Average"; - if (aggregateId == ObjectIds.AggregateFunction_Minimum) - return "Minimum"; - if (aggregateId == ObjectIds.AggregateFunction_Maximum) - return "Maximum"; - if (aggregateId == ObjectIds.AggregateFunction_Count) - return "ValueCount"; - if (aggregateId == ObjectIds.AggregateFunction_Start) - return "First"; - if (aggregateId == ObjectIds.AggregateFunction_End) - return "Last"; - if (aggregateId == ObjectIds.AggregateFunction_StandardDeviationPopulation) - return "StdDev"; - return null; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs deleted file mode 100644 index efa655a..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianClusterNodeState.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// Point-in-time state of a single historian cluster node. One entry per configured node is - /// surfaced inside so the status dashboard can render - /// per-node health and operators can see which nodes are in cooldown. - /// - public sealed class HistorianClusterNodeState - { - /// - /// Gets or sets the configured node hostname exactly as it appears in - /// HistorianConfiguration.ServerNames. - /// - public string Name { get; set; } = ""; - - /// - /// Gets or sets a value indicating whether the node is currently eligible for new connection - /// attempts. means the node is in its post-failure cooldown window - /// and the picker is skipping it. - /// - public bool IsHealthy { get; set; } - - /// - /// Gets or sets the UTC timestamp at which the node's cooldown expires, or - /// when the node is not in cooldown. - /// - public DateTime? CooldownUntil { get; set; } - - /// - /// Gets or sets the number of times this node has transitioned from healthy to failed - /// since startup. Does not decrement on recovery. - /// - public int FailureCount { get; set; } - - /// - /// Gets or sets the message from the most recent failure, or when - /// the node has never failed. - /// - public string? LastError { get; set; } - - /// - /// Gets or sets the UTC timestamp of the most recent failure, or - /// when the node has never failed. - /// - public DateTime? LastFailureTime { get; set; } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs deleted file mode 100644 index f1dfa11..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianEventDto.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// SDK-free representation of a Historian event record exposed by the historian plugin. - /// Prevents ArchestrA types from leaking into the Host assembly. - /// - public sealed class HistorianEventDto - { - public Guid Id { get; set; } - public string? Source { get; set; } - public DateTime EventTime { get; set; } - public DateTime ReceivedTime { get; set; } - public string? DisplayText { get; set; } - public ushort Severity { get; set; } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs deleted file mode 100644 index c37cb9b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianHealthSnapshot.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// Point-in-time runtime health of the historian plugin, surfaced to the status dashboard - /// and health check service. Fills the gap between the load-time plugin status - /// () and actual query behavior so operators - /// can detect silent query degradation. - /// - public sealed class HistorianHealthSnapshot - { - /// - /// Gets or sets the total number of historian read operations attempted since startup - /// across all read paths (raw, aggregate, at-time, events). - /// - public long TotalQueries { get; set; } - - /// - /// Gets or sets the total number of read operations that completed without an exception - /// being caught by the plugin's error handler. Includes empty result sets as successes — - /// the counter reflects "the SDK call returned" not "the SDK call returned data". - /// - public long TotalSuccesses { get; set; } - - /// - /// Gets or sets the total number of read operations that raised an exception. Each failure - /// also resets and closes the underlying SDK connection via the existing reconnect path. - /// - public long TotalFailures { get; set; } - - /// - /// Gets or sets the number of consecutive failures since the last success. Latches until - /// a successful query clears it. The health check service uses this as a degradation signal. - /// - public int ConsecutiveFailures { get; set; } - - /// - /// Gets or sets the UTC timestamp of the last successful read, or - /// when no query has succeeded since startup. - /// - public DateTime? LastSuccessTime { get; set; } - - /// - /// Gets or sets the UTC timestamp of the last failure, or when no - /// query has failed since startup. - /// - public DateTime? LastFailureTime { get; set; } - - /// - /// Gets or sets the exception message from the most recent failure. Cleared on the next - /// successful query. - /// - public string? LastError { get; set; } - - /// - /// Gets or sets a value indicating whether the plugin currently holds an open SDK - /// connection for the process (historical values) path. - /// - public bool ProcessConnectionOpen { get; set; } - - /// - /// Gets or sets a value indicating whether the plugin currently holds an open SDK - /// connection for the event (alarm history) path. - /// - public bool EventConnectionOpen { get; set; } - - /// - /// Gets or sets the node the plugin is currently connected to for the process path, - /// or when no connection is open. - /// - public string? ActiveProcessNode { get; set; } - - /// - /// Gets or sets the node the plugin is currently connected to for the event path, - /// or when no event connection is open. - /// - public string? ActiveEventNode { get; set; } - - /// - /// Gets or sets the total number of configured historian cluster nodes. A value of 1 - /// reflects a legacy single-node deployment. - /// - public int NodeCount { get; set; } - - /// - /// Gets or sets the number of configured nodes that are currently healthy (not in cooldown). - /// - public int HealthyNodeCount { get; set; } - - /// - /// Gets or sets the per-node cluster state in configuration order. - /// - public List Nodes { get; set; } = new(); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs deleted file mode 100644 index e4e13f4..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistorianPluginLoader.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// Result of the most recent historian plugin load attempt. - /// - public enum HistorianPluginStatus - { - /// Historian.Enabled is false; TryLoad was not called. - Disabled, - /// Plugin DLL was not present in the Historian/ subfolder. - NotFound, - /// Plugin file exists but could not be loaded or instantiated. - LoadFailed, - /// Plugin loaded and an IHistorianDataSource was constructed. - Loaded - } - - /// - /// Structured outcome of a or - /// call, used by the status dashboard. - /// - public sealed class HistorianPluginOutcome - { - public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error) - { - Status = status; - PluginPath = pluginPath; - Error = error; - } - - public HistorianPluginStatus Status { get; } - public string PluginPath { get; } - public string? Error { get; } - } - - /// - /// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to - /// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run - /// with Historian.Enabled=false. - /// - public static class HistorianPluginLoader - { - private const string PluginSubfolder = "Historian"; - private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva"; - private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry"; - private const string PluginEntryMethod = "Create"; - - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader)); - private static readonly object ResolverGate = new object(); - private static bool _resolverInstalled; - private static string? _resolvedProbeDirectory; - - /// - /// Gets the outcome of the most recent load attempt (or - /// if the loader has never been invoked). The dashboard reads this to distinguish "disabled", - /// "plugin missing", and "plugin crashed". - /// - public static HistorianPluginOutcome LastOutcome { get; private set; } - = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null); - - /// - /// Records that the historian plugin is disabled by configuration. Called by - /// OpcUaService when Historian.Enabled=false so the status dashboard can - /// report the exact reason history is unavailable. - /// - public static void MarkDisabled() - { - LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null); - } - - /// - /// Attempts to load the historian plugin and construct an . - /// Returns null on any failure so the server can continue with history unsupported. The - /// specific reason is published on . - /// - public static IHistorianDataSource? TryLoad(HistorianConfiguration config) - { - var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder); - var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll"); - - if (!File.Exists(pluginPath)) - { - Log.Warning( - "Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported", - pluginPath); - LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null); - return null; - } - - EnsureAssemblyResolverInstalled(pluginDirectory); - - try - { - var assembly = Assembly.LoadFrom(pluginPath); - var entryType = assembly.GetType(PluginEntryType, throwOnError: false); - if (entryType == null) - { - Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType); - LastOutcome = new HistorianPluginOutcome( - HistorianPluginStatus.LoadFailed, pluginPath, - $"Plugin assembly does not expose entry type {PluginEntryType}"); - return null; - } - - var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static); - if (create == null) - { - Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod); - LastOutcome = new HistorianPluginOutcome( - HistorianPluginStatus.LoadFailed, pluginPath, - $"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method"); - return null; - } - - var result = create.Invoke(null, new object[] { config }); - if (result is IHistorianDataSource dataSource) - { - Log.Information("Historian plugin loaded from {PluginPath}", pluginPath); - LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null); - return dataSource; - } - - Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath); - LastOutcome = new HistorianPluginOutcome( - HistorianPluginStatus.LoadFailed, pluginPath, - "Plugin entry method returned an object that does not implement IHistorianDataSource"); - return null; - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath); - LastOutcome = new HistorianPluginOutcome( - HistorianPluginStatus.LoadFailed, pluginPath, - ex.GetBaseException().Message); - return null; - } - } - - private static void EnsureAssemblyResolverInstalled(string pluginDirectory) - { - lock (ResolverGate) - { - _resolvedProbeDirectory = pluginDirectory; - if (_resolverInstalled) - return; - - AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory; - _resolverInstalled = true; - } - } - - private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args) - { - var probeDirectory = _resolvedProbeDirectory; - if (string.IsNullOrEmpty(probeDirectory)) - return null; - - var requested = new AssemblyName(args.Name); - var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll"); - if (!File.Exists(candidate)) - return null; - - try - { - return Assembly.LoadFrom(candidate); - } - catch (Exception ex) - { - Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate); - return null; - } - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs deleted file mode 100644 index ae7d553..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/HistoryContinuationPoint.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using Opc.Ua; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// Manages continuation points for OPC UA HistoryRead requests that return - /// more data than the per-request limit allows. - /// - internal sealed class HistoryContinuationPointManager - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly ConcurrentDictionary _store = new(); - private readonly TimeSpan _timeout; - - public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { } - - internal HistoryContinuationPointManager(TimeSpan timeout) - { - _timeout = timeout; - } - - /// - /// Stores remaining data values and returns a continuation point identifier. - /// - public byte[] Store(List remaining) - { - PurgeExpired(); - var id = Guid.NewGuid(); - _store[id] = new StoredContinuation(remaining, DateTime.UtcNow); - Log.Debug("Stored history continuation point {Id} with {Count} remaining values", id, remaining.Count); - return id.ToByteArray(); - } - - /// - /// Retrieves and removes the remaining data values for a continuation point. - /// Returns null if the continuation point is invalid or expired. - /// - public List? Retrieve(byte[] continuationPoint) - { - PurgeExpired(); - if (continuationPoint == null || continuationPoint.Length != 16) - return null; - - var id = new Guid(continuationPoint); - if (!_store.TryRemove(id, out var stored)) - return null; - - if (DateTime.UtcNow - stored.CreatedAt > _timeout) - { - Log.Debug("History continuation point {Id} expired", id); - return null; - } - - return stored.Values; - } - - /// - /// Releases a continuation point without retrieving its data. - /// - public void Release(byte[] continuationPoint) - { - PurgeExpired(); - if (continuationPoint == null || continuationPoint.Length != 16) - return; - - var id = new Guid(continuationPoint); - _store.TryRemove(id, out _); - } - - private void PurgeExpired() - { - var cutoff = DateTime.UtcNow - _timeout; - foreach (var kvp in _store) - { - if (kvp.Value.CreatedAt < cutoff) - _store.TryRemove(kvp.Key, out _); - } - } - - private sealed class StoredContinuation - { - public StoredContinuation(List values, DateTime createdAt) - { - Values = values; - CreatedAt = createdAt; - } - - public List Values { get; } - public DateTime CreatedAt { get; } - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs deleted file mode 100644 index 1a61745..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Historian/IHistorianDataSource.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Opc.Ua; - -namespace ZB.MOM.WW.OtOpcUa.Host.Historian -{ - /// - /// OPC UA-typed surface for the historian plugin. Host consumers depend only on this - /// interface so the Wonderware Historian SDK assemblies are not required unless the - /// plugin is loaded at runtime. - /// - public interface IHistorianDataSource : IDisposable - { - Task> ReadRawAsync( - string tagName, DateTime startTime, DateTime endTime, int maxValues, - CancellationToken ct = default); - - Task> ReadAggregateAsync( - string tagName, DateTime startTime, DateTime endTime, - double intervalMs, string aggregateColumn, - CancellationToken ct = default); - - Task> ReadAtTimeAsync( - string tagName, DateTime[] timestamps, - CancellationToken ct = default); - - Task> ReadEventsAsync( - string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, - CancellationToken ct = default); - - /// - /// Returns a runtime snapshot of query success/failure counters and connection state. - /// Consumed by the status dashboard and health check service so operators can detect - /// silent query degradation that the load-time plugin status can't catch. - /// - HistorianHealthSnapshot GetHealthSnapshot(); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs deleted file mode 100644 index 8cbec84..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Metrics/PerformanceMetrics.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.Metrics -{ - /// - /// Disposable scope returned by . (MXA-008) - /// - public interface ITimingScope : IDisposable - { - /// - /// Marks whether the timed bridge operation completed successfully. - /// - /// A value indicating whether the measured operation succeeded. - void SetSuccess(bool success); - } - - /// - /// Statistics snapshot for a single operation type. - /// - public class MetricsStatistics - { - /// - /// Gets or sets the total number of recorded executions for the operation. - /// - public long TotalCount { get; set; } - - /// - /// Gets or sets the number of recorded executions that completed successfully. - /// - public long SuccessCount { get; set; } - - /// - /// Gets or sets the ratio of successful executions to total executions. - /// - public double SuccessRate { get; set; } - - /// - /// Gets or sets the mean execution time in milliseconds across the recorded sample. - /// - public double AverageMilliseconds { get; set; } - - /// - /// Gets or sets the fastest recorded execution time in milliseconds. - /// - public double MinMilliseconds { get; set; } - - /// - /// Gets or sets the slowest recorded execution time in milliseconds. - /// - public double MaxMilliseconds { get; set; } - - /// - /// Gets or sets the 95th percentile execution time in milliseconds. - /// - public double Percentile95Milliseconds { get; set; } - } - - /// - /// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008) - /// - public class OperationMetrics - { - private readonly List _durations = new(); - private readonly object _lock = new(); - private double _maxMilliseconds; - private double _minMilliseconds = double.MaxValue; - private long _successCount; - private long _totalCount; - private double _totalMilliseconds; - - /// - /// Records the outcome and duration of a single bridge operation invocation. - /// - /// The elapsed time for the operation. - /// A value indicating whether the operation completed successfully. - public void Record(TimeSpan duration, bool success) - { - lock (_lock) - { - _totalCount++; - if (success) _successCount++; - - var ms = duration.TotalMilliseconds; - _durations.Add(ms); - _totalMilliseconds += ms; - - if (ms < _minMilliseconds) _minMilliseconds = ms; - if (ms > _maxMilliseconds) _maxMilliseconds = ms; - - if (_durations.Count > 1000) _durations.RemoveAt(0); - } - } - - /// - /// Creates a snapshot of the current statistics for this operation type. - /// - /// A statistics snapshot suitable for logs, status reporting, and tests. - public MetricsStatistics GetStatistics() - { - lock (_lock) - { - if (_totalCount == 0) - return new MetricsStatistics(); - - var sorted = _durations.OrderBy(d => d).ToList(); - var p95Index = Math.Max(0, (int)Math.Ceiling(sorted.Count * 0.95) - 1); - - return new MetricsStatistics - { - TotalCount = _totalCount, - SuccessCount = _successCount, - SuccessRate = (double)_successCount / _totalCount, - AverageMilliseconds = _totalMilliseconds / _totalCount, - MinMilliseconds = _minMilliseconds, - MaxMilliseconds = _maxMilliseconds, - Percentile95Milliseconds = sorted[p95Index] - }; - } - } - } - - /// - /// Tracks per-operation performance metrics with periodic logging. (MXA-008) - /// - public class PerformanceMetrics : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly ConcurrentDictionary - _metrics = new(StringComparer.OrdinalIgnoreCase); - - private readonly Timer _reportingTimer; - private bool _disposed; - - /// - /// Initializes a new metrics collector and starts periodic performance reporting. - /// - public PerformanceMetrics() - { - _reportingTimer = new Timer(ReportMetrics, null, - TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - - /// - /// Stops periodic reporting and emits a final metrics snapshot. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _reportingTimer.Dispose(); - ReportMetrics(null); - } - - /// - /// Records a completed bridge operation under the specified metrics bucket. - /// - /// The logical operation name, such as read, write, or subscribe. - /// The elapsed time for the operation. - /// A value indicating whether the operation completed successfully. - public void RecordOperation(string operationName, TimeSpan duration, bool success = true) - { - var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics()); - metrics.Record(duration, success); - } - - /// - /// Starts timing a bridge operation and returns a disposable scope that records the result when disposed. - /// - /// The logical operation name to record. - /// A timing scope that reports elapsed time back into this collector. - public ITimingScope BeginOperation(string operationName) - { - return new TimingScope(this, operationName); - } - - /// - /// Retrieves the raw metrics bucket for a named operation. - /// - /// The logical operation name to look up. - /// The metrics bucket when present; otherwise, . - public OperationMetrics? GetMetrics(string operationName) - { - return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null; - } - - /// - /// Produces a statistics snapshot for all recorded bridge operations. - /// - /// A dictionary keyed by operation name containing current metrics statistics. - public Dictionary GetStatistics() - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var kvp in _metrics) - result[kvp.Key] = kvp.Value.GetStatistics(); - return result; - } - - private void ReportMetrics(object? state) - { - foreach (var kvp in _metrics) - { - var stats = kvp.Value.GetStatistics(); - if (stats.TotalCount == 0) continue; - - Logger.Information( - "Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " + - "AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}", - kvp.Key, stats.TotalCount, stats.SuccessRate, - stats.AverageMilliseconds, stats.MinMilliseconds, - stats.MaxMilliseconds, stats.Percentile95Milliseconds); - } - } - - /// - /// Timing scope that records one operation result into the owning metrics collector. - /// - private class TimingScope : ITimingScope - { - private readonly PerformanceMetrics _metrics; - private readonly string _operationName; - private readonly Stopwatch _stopwatch; - private bool _disposed; - private bool _success = true; - - /// - /// Initializes a timing scope for a named bridge operation. - /// - /// The metrics collector that should receive the result. - /// The logical operation name being timed. - public TimingScope(PerformanceMetrics metrics, string operationName) - { - _metrics = metrics; - _operationName = operationName; - _stopwatch = Stopwatch.StartNew(); - } - - /// - /// Marks whether the timed operation should be recorded as successful. - /// - /// A value indicating whether the operation succeeded. - public void SetSuccess(bool success) - { - _success = success; - } - - /// - /// Stops timing and records the operation result once. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _stopwatch.Stop(); - _metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success); - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs deleted file mode 100644 index f1741a0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs +++ /dev/null @@ -1,472 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - /// - /// Advises <ObjectName>.ScanState on every deployed $WinPlatform and - /// $AppEngine, tracks their runtime state (Unknown / Running / Stopped), and notifies - /// the owning node manager on Running↔Stopped transitions so it can proactively flip every - /// OPC UA variable hosted by that object to BadOutOfService (and clear on recovery). - /// - /// - /// State machine semantics are documented in runtimestatus.md. Key facts: - /// - /// ScanState is delivered on-change only — no periodic heartbeat. A stably - /// Running host may go hours without a callback. - /// Running → Stopped is driven by explicit error callbacks or ScanState = false, - /// NEVER by starvation. The only starvation check applies to the initial Unknown state. - /// When the MxAccess transport is disconnected, returns every - /// entry with regardless of the underlying state, - /// because we can't observe anything through a dead transport. - /// The stop/start callbacks fire synchronously from whichever thread delivered the - /// probe update. The manager releases its own lock before invoking them to avoid - /// lock-inversion deadlocks with the node manager's Lock. - /// - /// - public sealed class GalaxyRuntimeProbeManager : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private const int CategoryWinPlatform = 1; - private const int CategoryAppEngine = 3; - private const string KindWinPlatform = "$WinPlatform"; - private const string KindAppEngine = "$AppEngine"; - private const string ProbeAttribute = ".ScanState"; - - private readonly IMxAccessClient _client; - private readonly TimeSpan _unknownTimeout; - private readonly Action? _onHostStopped; - private readonly Action? _onHostRunning; - private readonly Func _clock; - - // Key: probe tag reference (e.g. "DevAppEngine.ScanState"). - // Value: the current runtime status for that host, kept in sync on every probe callback - // and queried via GetSnapshot for dashboard rendering. - private readonly Dictionary _byProbe = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently. - private readonly Dictionary _probeByGobjectId = new Dictionary(); - - private readonly object _lock = new object(); - private bool _disposed; - - /// - /// Initializes a new probe manager. and - /// are invoked synchronously on Running↔Stopped - /// transitions so the owning node manager can invalidate / restore the hosted subtree. - /// - public GalaxyRuntimeProbeManager( - IMxAccessClient client, - int unknownTimeoutSeconds, - Action? onHostStopped = null, - Action? onHostRunning = null) - : this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow) - { - } - - internal GalaxyRuntimeProbeManager( - IMxAccessClient client, - int unknownTimeoutSeconds, - Action? onHostStopped, - Action? onHostRunning, - Func clock) - { - _client = client ?? throw new ArgumentNullException(nameof(client)); - _unknownTimeout = TimeSpan.FromSeconds(Math.Max(1, unknownTimeoutSeconds)); - _onHostStopped = onHostStopped; - _onHostRunning = onHostRunning; - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - } - - /// - /// Gets the number of active probe subscriptions. Surfaced on the dashboard Subscriptions - /// panel so operators can see bridge-owned probe count separately from the total. - /// - public int ActiveProbeCount - { - get - { - lock (_lock) - return _byProbe.Count; - } - } - - /// - /// Returns when the galaxy runtime host identified by - /// is currently in the - /// state. Used by the node manager's Read path to short-circuit on-demand reads of tags - /// hosted by a known-stopped runtime object, preventing MxAccess from serving stale - /// cached values as Good. Unlike this check uses the - /// underlying state directly — transport-disconnected hosts will NOT report Stopped here - /// (they report their last-known state), because connection-loss is handled by the - /// normal MxAccess error paths and we don't want this method to double-flag. - /// - public bool IsHostStopped(int gobjectId) - { - lock (_lock) - { - if (_probeByGobjectId.TryGetValue(gobjectId, out var probe) - && _byProbe.TryGetValue(probe, out var status)) - { - return status.State == GalaxyRuntimeState.Stopped; - } - } - return false; - } - - /// - /// Returns a point-in-time clone of the runtime status for the host identified by - /// , or when no probe is registered - /// for that object. Used by the node manager to populate the synthetic $RuntimeState - /// child variables on each host object. Uses the underlying state directly (not the - /// transport-gated rewrite), matching . - /// - public GalaxyRuntimeStatus? GetHostStatus(int gobjectId) - { - lock (_lock) - { - if (_probeByGobjectId.TryGetValue(gobjectId, out var probe) - && _byProbe.TryGetValue(probe, out var status)) - { - return Clone(status, forceUnknown: false); - } - } - return null; - } - - /// - /// Diffs the supplied hierarchy against the active probe set, advising new hosts and - /// unadvising removed ones. The hierarchy is filtered to runtime host categories - /// ($WinPlatform, $AppEngine) — non-host rows are ignored. Idempotent: a second call - /// with the same hierarchy performs no Advise / Unadvise work. - /// - /// - /// Sync is synchronous on MxAccess: is - /// awaited for each new host, so for a galaxy with N runtime hosts the call blocks for - /// ~N round-trips. This is acceptable because it only runs during address-space build - /// and rebuild, not on the hot path. - /// - public async Task SyncAsync(IReadOnlyList hierarchy) - { - if (_disposed || hierarchy == null) - return; - - // Filter to runtime hosts and project to the expected probe tag name. - var desired = new Dictionary(); - foreach (var obj in hierarchy) - { - if (obj.CategoryId != CategoryWinPlatform && obj.CategoryId != CategoryAppEngine) - continue; - if (string.IsNullOrWhiteSpace(obj.TagName)) - continue; - var probe = obj.TagName + ProbeAttribute; - var kind = obj.CategoryId == CategoryWinPlatform ? KindWinPlatform : KindAppEngine; - desired[obj.GobjectId] = (probe, kind, obj); - } - - // Compute diffs under lock, release lock before issuing SDK calls (which can block). - // toSubscribe carries the gobject id alongside the probe name so the rollback path on - // subscribe failure can unwind both dictionaries without a reverse lookup. - List<(int GobjectId, string Probe)> toSubscribe; - List toUnsubscribe; - lock (_lock) - { - toSubscribe = new List<(int, string)>(); - toUnsubscribe = new List(); - - foreach (var kvp in desired) - { - if (_probeByGobjectId.TryGetValue(kvp.Key, out var existingProbe)) - { - // Already tracked: ensure the status entry is aligned (tag rename path is - // intentionally not supported — if the probe changed, treat it as remove+add). - if (!string.Equals(existingProbe, kvp.Value.Probe, StringComparison.OrdinalIgnoreCase)) - { - toUnsubscribe.Add(existingProbe); - _byProbe.Remove(existingProbe); - _probeByGobjectId.Remove(kvp.Key); - - toSubscribe.Add((kvp.Key, kvp.Value.Probe)); - _byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind); - _probeByGobjectId[kvp.Key] = kvp.Value.Probe; - } - } - else - { - toSubscribe.Add((kvp.Key, kvp.Value.Probe)); - _byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind); - _probeByGobjectId[kvp.Key] = kvp.Value.Probe; - } - } - - // Remove hosts that are no longer in the desired set. - var toRemove = _probeByGobjectId.Keys.Where(id => !desired.ContainsKey(id)).ToList(); - foreach (var id in toRemove) - { - var probe = _probeByGobjectId[id]; - toUnsubscribe.Add(probe); - _byProbe.Remove(probe); - _probeByGobjectId.Remove(id); - } - } - - // Apply the diff outside the lock. - foreach (var (gobjectId, probe) in toSubscribe) - { - try - { - await _client.SubscribeAsync(probe, OnProbeValueChanged); - Log.Information("Galaxy runtime probe advised: {Probe}", probe); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to advise galaxy runtime probe {Probe}", probe); - - // Roll back the pending entry so Tick() can't later transition a never-advised - // probe from Unknown to Stopped and fan out a false-negative host-down signal. - // A concurrent SyncAsync may have re-added the same gobject under a new probe - // name, so compare against the captured probe string before removing. - lock (_lock) - { - if (_probeByGobjectId.TryGetValue(gobjectId, out var current) - && string.Equals(current, probe, StringComparison.OrdinalIgnoreCase)) - { - _probeByGobjectId.Remove(gobjectId); - } - _byProbe.Remove(probe); - } - } - } - - foreach (var probe in toUnsubscribe) - { - try - { - await _client.UnsubscribeAsync(probe); - } - catch (Exception ex) - { - Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during sync", probe); - } - } - } - - /// - /// Routes an OnTagValueChanged callback to the probe state machine. Returns - /// when matches a bridge-owned probe - /// (in which case the owning node manager should skip its normal variable-update path). - /// - public bool HandleProbeUpdate(string tagRef, Vtq vtq) - { - if (_disposed || string.IsNullOrEmpty(tagRef)) - return false; - - GalaxyRuntimeStatus? status; - int fromToGobjectId = 0; - GalaxyRuntimeState? transitionTo = null; - - lock (_lock) - { - if (!_byProbe.TryGetValue(tagRef, out status)) - return false; // not a probe — let the caller handle it normally - - var now = _clock(); - var isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b; - status.LastStateCallbackTime = now; - status.LastScanState = vtq.Value as bool?; - - if (isRunning) - { - status.GoodUpdateCount++; - status.LastError = null; - if (status.State != GalaxyRuntimeState.Running) - { - // Only fire the host-running callback on a true Stopped → Running - // recovery. Unknown → Running happens once at startup for every host - // and is not a recovery — firing ClearHostVariablesBadQuality there - // would wipe Bad status set by the concurrently-stopping other host - // on variables that span both lists. - var wasStopped = status.State == GalaxyRuntimeState.Stopped; - status.State = GalaxyRuntimeState.Running; - status.LastStateChangeTime = now; - if (wasStopped) - { - transitionTo = GalaxyRuntimeState.Running; - fromToGobjectId = status.GobjectId; - } - } - } - else - { - status.FailureCount++; - status.LastError = BuildErrorDetail(vtq); - if (status.State != GalaxyRuntimeState.Stopped) - { - status.State = GalaxyRuntimeState.Stopped; - status.LastStateChangeTime = now; - transitionTo = GalaxyRuntimeState.Stopped; - fromToGobjectId = status.GobjectId; - } - } - } - - // Invoke transition callbacks outside the lock to avoid inverting the node manager's - // lock order when it subsequently takes its own Lock to flip hosted variables. - if (transitionTo == GalaxyRuntimeState.Stopped) - { - Log.Information("Galaxy runtime {Probe} transitioned Running → Stopped ({Err})", - tagRef, status?.LastError ?? "(no detail)"); - try { _onHostStopped?.Invoke(fromToGobjectId); } - catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw for {Probe}", tagRef); } - } - else if (transitionTo == GalaxyRuntimeState.Running) - { - Log.Information("Galaxy runtime {Probe} transitioned → Running", tagRef); - try { _onHostRunning?.Invoke(fromToGobjectId); } - catch (Exception ex) { Log.Warning(ex, "onHostRunning callback threw for {Probe}", tagRef); } - } - - return true; - } - - /// - /// Periodic tick — flips Unknown entries to Stopped once their registration has been - /// outstanding for longer than the configured timeout without ever receiving a first - /// callback. Does nothing to Running or Stopped entries. - /// - public void Tick() - { - if (_disposed) - return; - - var transitions = new List(); - lock (_lock) - { - var now = _clock(); - foreach (var entry in _byProbe.Values) - { - if (entry.State != GalaxyRuntimeState.Unknown) - continue; - - // LastStateChangeTime is set at creation to "now" so the timeout is measured - // from when the probe was advised. - if (entry.LastStateChangeTime.HasValue - && now - entry.LastStateChangeTime.Value > _unknownTimeout) - { - entry.State = GalaxyRuntimeState.Stopped; - entry.LastStateChangeTime = now; - entry.FailureCount++; - entry.LastError = "Probe never received an initial callback within the unknown-resolution timeout"; - transitions.Add(entry.GobjectId); - } - } - } - - foreach (var gobjectId in transitions) - { - Log.Warning("Galaxy runtime gobject {GobjectId} timed out in Unknown state → Stopped", gobjectId); - try { _onHostStopped?.Invoke(gobjectId); } - catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw during tick for {GobjectId}", gobjectId); } - } - } - - /// - /// Returns a read-only snapshot of every tracked host. When the MxAccess transport is - /// disconnected, every entry is rewritten to Unknown on the way out so operators aren't - /// misled by cached per-host state — the Connection panel is the primary signal in that - /// case. The underlying _byProbe map is not modified. - /// - public IReadOnlyList GetSnapshot() - { - var transportDown = _client.State != ConnectionState.Connected; - - lock (_lock) - { - var result = new List(_byProbe.Count); - foreach (var entry in _byProbe.Values) - result.Add(Clone(entry, forceUnknown: transportDown)); - // Stable ordering by name so dashboard rows don't jitter between refreshes. - result.Sort((a, b) => string.CompareOrdinal(a.ObjectName, b.ObjectName)); - return result; - } - } - - /// - public void Dispose() - { - List probes; - lock (_lock) - { - if (_disposed) - return; - _disposed = true; - probes = _byProbe.Keys.ToList(); - _byProbe.Clear(); - _probeByGobjectId.Clear(); - } - - foreach (var probe in probes) - { - try - { - _client.UnsubscribeAsync(probe).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during Dispose", probe); - } - } - } - - private void OnProbeValueChanged(string tagRef, Vtq vtq) - { - HandleProbeUpdate(tagRef, vtq); - } - - private GalaxyRuntimeStatus MakeInitialStatus(GalaxyObjectInfo obj, string kind) - { - return new GalaxyRuntimeStatus - { - ObjectName = obj.TagName, - GobjectId = obj.GobjectId, - Kind = kind, - State = GalaxyRuntimeState.Unknown, - LastStateChangeTime = _clock() - }; - } - - private static GalaxyRuntimeStatus Clone(GalaxyRuntimeStatus src, bool forceUnknown) - { - return new GalaxyRuntimeStatus - { - ObjectName = src.ObjectName, - GobjectId = src.GobjectId, - Kind = src.Kind, - State = forceUnknown ? GalaxyRuntimeState.Unknown : src.State, - LastStateCallbackTime = src.LastStateCallbackTime, - LastStateChangeTime = src.LastStateChangeTime, - LastScanState = src.LastScanState, - LastError = forceUnknown ? null : src.LastError, - GoodUpdateCount = src.GoodUpdateCount, - FailureCount = src.FailureCount - }; - } - - private static string BuildErrorDetail(Vtq vtq) - { - if (vtq.Quality.IsBad()) - return $"bad quality ({vtq.Quality})"; - if (vtq.Quality.IsUncertain()) - return $"uncertain quality ({vtq.Quality})"; - if (vtq.Value is bool b && !b) - return "ScanState = false (OffScan)"; - return $"unexpected value: {vtq.Value ?? "(null)"}"; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs deleted file mode 100644 index 6875a65..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription. - /// - /// A token that cancels the connection attempt. - public async Task ConnectAsync(CancellationToken ct = default) - { - if (_state == ConnectionState.Connected) return; - - SetState(ConnectionState.Connecting); - try - { - _connectionHandle = await _staThread.RunAsync(() => - { - AttachProxyEvents(); - return _proxy.Register(_config.ClientName); - }); - - Log.Information("MxAccess registered with handle {Handle}", _connectionHandle); - SetState(ConnectionState.Connected); - - // Replay stored subscriptions - await ReplayStoredSubscriptionsAsync(); - - // Start probe if configured - if (!string.IsNullOrWhiteSpace(_config.ProbeTag)) - { - _probeTag = _config.ProbeTag; - _lastProbeValueTime = DateTime.UtcNow; - await SubscribeInternalAsync(_probeTag!); - Log.Information("Probe tag subscribed: {ProbeTag}", _probeTag); - } - } - catch (Exception ex) - { - try - { - await _staThread.RunAsync(DetachProxyEvents); - } - catch (Exception cleanupEx) - { - Log.Warning(cleanupEx, "Failed to detach proxy events after connection failure"); - } - - Log.Error(ex, "MxAccess connection failed"); - SetState(ConnectionState.Error, ex.Message); - throw; - } - } - - /// - /// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations. - /// - public async Task DisconnectAsync() - { - if (_state == ConnectionState.Disconnected) return; - - SetState(ConnectionState.Disconnecting); - try - { - await _staThread.RunAsync(() => - { - // UnAdvise + RemoveItem for all active subscriptions - foreach (var kvp in _addressToHandle) - try - { - _proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value); - _proxy.RemoveItem(_connectionHandle, kvp.Value); - } - catch (Exception ex) - { - Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key); - } - - // Unwire events before unregister - DetachProxyEvents(); - - // Unregister - try - { - _proxy.Unregister(_connectionHandle); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during Unregister"); - } - }); - - _handleToAddress.Clear(); - _addressToHandle.Clear(); - _pendingReadsByAddress.Clear(); - _pendingWrites.Clear(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during disconnect"); - } - finally - { - SetState(ConnectionState.Disconnected); - } - } - - /// - /// Attempts to recover from a runtime fault by disconnecting and reconnecting the client. - /// - public async Task ReconnectAsync() - { - SetState(ConnectionState.Reconnecting); - Interlocked.Increment(ref _reconnectCount); - Log.Information("MxAccess reconnect attempt #{Count}", _reconnectCount); - - try - { - await DisconnectAsync(); - await ConnectAsync(); - } - catch (Exception ex) - { - Log.Error(ex, "Reconnect failed"); - SetState(ConnectionState.Error, ex.Message); - } - } - - private void AttachProxyEvents() - { - if (_proxyEventsAttached) return; - _proxy.OnDataChange += HandleOnDataChange; - _proxy.OnWriteComplete += HandleOnWriteComplete; - _proxyEventsAttached = true; - } - - private void DetachProxyEvents() - { - if (!_proxyEventsAttached) return; - _proxy.OnDataChange -= HandleOnDataChange; - _proxy.OnWriteComplete -= HandleOnWriteComplete; - _proxyEventsAttached = false; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs deleted file mode 100644 index 588a627..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using ArchestrA.MxAccess; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// COM event handler for MxAccess OnDataChange events. - /// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface. - /// - private void HandleOnDataChange( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - if (!_handleToAddress.TryGetValue(phItemHandle, out var address)) - { - Log.Debug("OnDataChange for unknown handle {Handle}", phItemHandle); - return; - } - - var quality = QualityMapper.MapFromMxAccessQuality(pwItemQuality); - - // Check MXSTATUS_PROXY — if success is false, use more specific quality - if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0) - quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail); - - var timestamp = ConvertTimestamp(pftItemTimeStamp); - var vtq = new Vtq(pvItemValue, timestamp, quality); - - // Update probe timestamp - if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase)) - _lastProbeValueTime = DateTime.UtcNow; - - // Invoke stored subscription callback - if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq); - - if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads)) - foreach (var pendingRead in pendingReads.Values) - pendingRead.TrySetResult(vtq); - - // Global handler - OnTagValueChanged?.Invoke(address, vtq); - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnDataChange for handle {Handle}", phItemHandle); - } - } - - /// - /// COM event handler for MxAccess OnWriteComplete events. - /// - private void HandleOnWriteComplete( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - if (_pendingWrites.TryRemove(phItemHandle, out var tcs)) - { - var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0; - if (success) - { - tcs.TrySetResult(true); - } - else - { - var detail = ItemStatus![0].detail; - var message = MxErrorCodes.GetMessage(detail); - Log.Warning("Write failed for handle {Handle}: {Message}", phItemHandle, message); - tcs.TrySetResult(false); - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnWriteComplete for handle {Handle}", phItemHandle); - } - } - - private static DateTime ConvertTimestamp(object pftItemTimeStamp) - { - if (pftItemTimeStamp is DateTime dt) - return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); - return DateTime.UtcNow; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs deleted file mode 100644 index 3f3fd84..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - private Task? _monitorTask; - - /// - /// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness. - /// - public void StartMonitor() - { - if (_monitorCts != null) - StopMonitor(); - - _monitorCts = new CancellationTokenSource(); - _monitorTask = Task.Run(() => MonitorLoopAsync(_monitorCts.Token)); - Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds); - } - - /// - /// Stops the background monitor loop. - /// - public void StopMonitor() - { - _monitorCts?.Cancel(); - try { _monitorTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ } - _monitorTask = null; - } - - private async Task MonitorLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromSeconds(_config.MonitorIntervalSeconds), ct); - } - catch (OperationCanceledException) - { - break; - } - - try - { - if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) && - _config.AutoReconnect) - { - Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state); - await ReconnectAsync(); - continue; - } - - if (_state == ConnectionState.Connected && _probeTag != null) - { - var elapsed = DateTime.UtcNow - _lastProbeValueTime; - if (elapsed.TotalSeconds > _config.ProbeStaleThresholdSeconds) - { - Log.Warning("Monitor: probe stale ({Elapsed:F0}s > {Threshold}s), forcing reconnect", - elapsed.TotalSeconds, _config.ProbeStaleThresholdSeconds); - await ReconnectAsync(); - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Monitor loop error"); - } - } - - Log.Information("MxAccess monitor stopped"); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs deleted file mode 100644 index 313c49b..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback. - /// - /// The fully qualified Galaxy tag reference to read. - /// A token that cancels the read. - /// The resulting VTQ value or a bad-quality fallback on timeout or failure. - public async Task ReadAsync(string fullTagReference, CancellationToken ct = default) - { - if (_state != ConnectionState.Connected) - return Vtq.Bad(Quality.BadNotConnected); - - await _operationSemaphore.WaitAsync(ct); - try - { - using var scope = _metrics.BeginOperation("Read"); - var tcs = new TaskCompletionSource(); - - var itemHandle = await _staThread.RunAsync(() => - { - var h = _proxy.AddItem(_connectionHandle, fullTagReference); - _proxy.AdviseSupervisory(_connectionHandle, h); - return h; - }); - - var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference, - _ => new ConcurrentDictionary>()); - pendingReads[itemHandle] = tcs; - _handleToAddress[itemHandle] = fullTagReference; - - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds)); - cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure))); - - var result = await tcs.Task; - if (result.Quality != Quality.Good) - scope.SetSuccess(false); - - return result; - } - catch - { - scope.SetSuccess(false); - return Vtq.Bad(Quality.BadCommFailure); - } - finally - { - if (_pendingReadsByAddress.TryGetValue(fullTagReference, out var reads)) - { - reads.TryRemove(itemHandle, out _); - if (reads.IsEmpty) - _pendingReadsByAddress.TryRemove(fullTagReference, out _); - } - - _handleToAddress.TryRemove(itemHandle, out _); - - try - { - await _staThread.RunAsync(() => - { - _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle); - _proxy.RemoveItem(_connectionHandle, itemHandle); - }); - } - catch (Exception ex) - { - Log.Warning(ex, "Error cleaning up read subscription for {Address}", fullTagReference); - } - } - } - finally - { - _operationSemaphore.Release(); - } - } - - /// - /// Writes a value to a Galaxy tag and waits for the runtime write-complete callback. - /// - /// The fully qualified Galaxy tag reference to write. - /// The value to send to the runtime. - /// A token that cancels the write. - /// when the runtime acknowledges success; otherwise, . - public async Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) - { - if (_state != ConnectionState.Connected) return false; - - await _operationSemaphore.WaitAsync(ct); - try - { - using var scope = _metrics.BeginOperation("Write"); - - var itemHandle = await _staThread.RunAsync(() => - { - var h = _proxy.AddItem(_connectionHandle, fullTagReference); - _proxy.AdviseSupervisory(_connectionHandle, h); - return h; - }); - - _handleToAddress[itemHandle] = fullTagReference; - - var tcs = new TaskCompletionSource(); - _pendingWrites[itemHandle] = tcs; - - try - { - await _staThread.RunAsync(() => _proxy.Write(_connectionHandle, itemHandle, value, -1)); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds)); - cts.Token.Register(() => - { - Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference, - _config.WriteTimeoutSeconds); - tcs.TrySetResult(false); - }); - - var success = await tcs.Task; - if (!success) - scope.SetSuccess(false); - - return success; - } - catch (Exception ex) - { - scope.SetSuccess(false); - Log.Error(ex, "Write failed for {Address}", fullTagReference); - return false; - } - finally - { - _pendingWrites.TryRemove(itemHandle, out _); - _handleToAddress.TryRemove(itemHandle, out _); - - try - { - await _staThread.RunAsync(() => - { - _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle); - _proxy.RemoveItem(_connectionHandle, itemHandle); - }); - } - catch (Exception ex) - { - Log.Warning(ex, "Error cleaning up write subscription for {Address}", fullTagReference); - } - } - } - finally - { - _operationSemaphore.Release(); - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs deleted file mode 100644 index 304bcb4..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected. - /// - /// The fully qualified Galaxy tag reference to monitor. - /// The callback that should receive runtime value changes. - public async Task SubscribeAsync(string fullTagReference, Action callback) - { - _storedSubscriptions[fullTagReference] = callback; - if (_state != ConnectionState.Connected) return; - if (_addressToHandle.ContainsKey(fullTagReference)) return; - - await SubscribeInternalAsync(fullTagReference); - } - - /// - /// Removes a persistent subscription callback and tears down the runtime item when appropriate. - /// - /// The fully qualified Galaxy tag reference to stop monitoring. - public async Task UnsubscribeAsync(string fullTagReference) - { - _storedSubscriptions.TryRemove(fullTagReference, out _); - - // Don't unsubscribe the probe tag - if (string.Equals(fullTagReference, _probeTag, StringComparison.OrdinalIgnoreCase)) - return; - - if (_addressToHandle.TryRemove(fullTagReference, out var itemHandle)) - { - _handleToAddress.TryRemove(itemHandle, out _); - - if (_state == ConnectionState.Connected) - await _staThread.RunAsync(() => - { - try - { - _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle); - _proxy.RemoveItem(_connectionHandle, itemHandle); - } - catch (Exception ex) - { - Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference); - } - }); - } - } - - private async Task SubscribeInternalAsync(string address) - { - if (_addressToHandle.ContainsKey(address)) - return; - - using var scope = _metrics.BeginOperation("Subscribe"); - try - { - var itemHandle = await _staThread.RunAsync(() => - { - var h = _proxy.AddItem(_connectionHandle, address); - _proxy.AdviseSupervisory(_connectionHandle, h); - return h; - }); - - var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle); - if (registeredHandle != itemHandle) - { - await _staThread.RunAsync(() => - { - _proxy.UnAdviseSupervisory(_connectionHandle, itemHandle); - _proxy.RemoveItem(_connectionHandle, itemHandle); - }); - return; - } - - _handleToAddress[itemHandle] = address; - Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle); - } - catch (Exception ex) - { - scope.SetSuccess(false); - Log.Error(ex, "Failed to subscribe to {Address}", address); - throw; - } - } - - private async Task ReplayStoredSubscriptionsAsync() - { - foreach (var kvp in _storedSubscriptions) - try - { - await SubscribeInternalAsync(kvp.Key); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key); - } - - Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs deleted file mode 100644 index 32815a3..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - /// - /// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction. - /// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor. - /// (MXA-001 through MXA-009) - /// - public sealed partial class MxAccessClient : IMxAccessClient - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly ConcurrentDictionary _addressToHandle = new(StringComparer.OrdinalIgnoreCase); - private readonly MxAccessConfiguration _config; - - // Handle mappings - private readonly ConcurrentDictionary _handleToAddress = new(); - private readonly PerformanceMetrics _metrics; - private readonly SemaphoreSlim _operationSemaphore; - - private readonly ConcurrentDictionary>> - _pendingReadsByAddress - = new(StringComparer.OrdinalIgnoreCase); - - // Pending writes - private readonly ConcurrentDictionary> _pendingWrites = new(); - - private readonly IMxProxy _proxy; - - private readonly StaComThread _staThread; - - // Subscription storage - private readonly ConcurrentDictionary> _storedSubscriptions - = new(StringComparer.OrdinalIgnoreCase); - - private int _connectionHandle; - private DateTime _lastProbeValueTime = DateTime.UtcNow; - private CancellationTokenSource? _monitorCts; - - // Probe - private string? _probeTag; - private bool _proxyEventsAttached; - private int _reconnectCount; - private volatile ConnectionState _state = ConnectionState.Disconnected; - - /// - /// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings. - /// - /// The STA thread used to marshal COM interactions. - /// The COM proxy abstraction used to talk to the runtime. - /// The runtime timeout, throttling, and reconnect settings. - /// The metrics collector used to time MXAccess operations. - public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, - PerformanceMetrics metrics) - { - _staThread = staThread; - _proxy = proxy; - _config = config; - _metrics = metrics; - _operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations); - } - - /// - /// Gets the current runtime connection state for the MXAccess client. - /// - public ConnectionState State => _state; - - /// - /// Gets the number of active tag subscriptions currently maintained against the runtime. - /// - public int ActiveSubscriptionCount => _storedSubscriptions.Count; - - /// - /// Gets the number of reconnect attempts performed since the client was created. - /// - public int ReconnectCount => _reconnectCount; - - /// - /// Occurs when the MXAccess connection state changes. - /// - public event EventHandler? ConnectionStateChanged; - - /// - /// Occurs when a subscribed runtime tag publishes a new value. - /// - public event Action? OnTagValueChanged; - - /// - /// Cancels monitoring and disconnects the runtime session before releasing local resources. - /// - public void Dispose() - { - try - { - _monitorCts?.Cancel(); - DisconnectAsync().GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during MxAccessClient dispose"); - } - finally - { - _operationSemaphore.Dispose(); - _monitorCts?.Dispose(); - } - } - - private void SetState(ConnectionState newState, string message = "") - { - var previous = _state; - if (previous == newState) return; - _state = newState; - Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message); - ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message)); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs deleted file mode 100644 index 6d4a6e5..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using ArchestrA.MxAccess; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - /// - /// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy. - /// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001) - /// - public sealed class MxProxyAdapter : IMxProxy - { - private LMXProxyServer? _lmxProxy; - - /// - /// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute. - /// - public event MxDataChangeHandler? OnDataChange; - - /// - /// Occurs when the COM proxy confirms completion of a write request. - /// - public event MxWriteCompleteHandler? OnWriteComplete; - - /// - /// Creates and registers the COM proxy session that backs live MXAccess operations. - /// - /// The client name reported to the Wonderware runtime. - /// The runtime connection handle assigned by the COM server. - 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; - } - - /// - /// Unregisters the COM proxy session and releases the underlying COM object. - /// - /// The runtime connection handle returned by . - public void Unregister(int handle) - { - if (_lmxProxy != null) - try - { - _lmxProxy.OnDataChange -= ProxyOnDataChange; - _lmxProxy.OnWriteComplete -= ProxyOnWriteComplete; - _lmxProxy.Unregister(handle); - } - finally - { - Marshal.ReleaseComObject(_lmxProxy); - _lmxProxy = null; - } - } - - /// - /// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy. - /// - /// The runtime connection handle. - /// The fully qualified Galaxy attribute reference. - /// The item handle assigned by the COM proxy. - public int AddItem(int handle, string address) - { - return _lmxProxy!.AddItem(handle, address); - } - - /// - /// Removes an item handle from the active COM proxy session. - /// - /// The runtime connection handle. - /// The item handle to remove. - public void RemoveItem(int handle, int itemHandle) - { - _lmxProxy!.RemoveItem(handle, itemHandle); - } - - /// - /// Enables supervisory callbacks for the specified runtime item. - /// - /// The runtime connection handle. - /// The item handle to monitor. - public void AdviseSupervisory(int handle, int itemHandle) - { - _lmxProxy!.AdviseSupervisory(handle, itemHandle); - } - - /// - /// Disables supervisory callbacks for the specified runtime item. - /// - /// The runtime connection handle. - /// The item handle to stop monitoring. - public void UnAdviseSupervisory(int handle, int itemHandle) - { - _lmxProxy!.UnAdvise(handle, itemHandle); - } - - /// - /// Writes a value to the specified runtime item through the COM proxy. - /// - /// The runtime connection handle. - /// The item handle to write. - /// The value to send to the runtime. - /// The Wonderware security classification applied to the write. - 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); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs b/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs deleted file mode 100644 index ca7d797..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess -{ - /// - /// Dedicated STA thread with a raw Win32 message pump for COM interop. - /// All MxAccess COM objects must be created and called on this thread. (MXA-001) - /// - public sealed class StaComThread : IDisposable - { - private const uint WM_APP = 0x8000; - private const uint PM_NOREMOVE = 0x0000; - - private static readonly ILogger Log = Serilog.Log.ForContext(); - private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5); - private readonly TaskCompletionSource _ready = new(); - - private readonly Thread _thread; - private readonly ConcurrentQueue _workItems = new(); - private long _appMessages; - private long _dispatchedMessages; - private bool _disposed; - private DateTime _lastLogTime; - private volatile uint _nativeThreadId; - private volatile bool _pumpExited; - - private long _totalMessages; - private long _workItemsExecuted; - - /// - /// Initializes a dedicated STA thread wrapper for Wonderware COM interop. - /// - public StaComThread() - { - _thread = new Thread(ThreadEntry) - { - Name = "MxAccess-STA", - IsBackground = true - }; - _thread.SetApartmentState(ApartmentState.STA); - } - - /// - /// Gets a value indicating whether the STA thread is running and able to accept work. - /// - public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited; - - /// - /// Stops the STA thread and releases the message-pump resources used for COM interop. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - if (_nativeThreadId != 0 && !_pumpExited) - PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero); - _thread.Join(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - Log.Warning(ex, "Error shutting down STA COM thread"); - } - - DrainAndFaultQueue(); - Log.Information("STA COM thread stopped"); - } - - /// - /// Starts the STA thread and waits until its message pump is ready for COM work. - /// - public void Start() - { - _thread.Start(); - _ready.Task.GetAwaiter().GetResult(); - Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId); - } - - /// - /// Queues an action to execute on the STA thread. - /// - /// The work item to execute on the STA thread. - /// A task that completes when the action has finished executing. - public Task RunAsync(Action action) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaComThread)); - if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited"); - - var tcs = new TaskCompletionSource(); - _workItems.Enqueue(new WorkItem - { - Execute = () => - { - try - { - action(); - tcs.TrySetResult(true); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }, - Fault = ex => tcs.TrySetException(ex) - }); - - if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero)) - { - _pumpExited = true; - DrainAndFaultQueue(); - } - - return tcs.Task; - } - - /// - /// Queues a function to execute on the STA thread and returns its result. - /// - /// The result type produced by the function. - /// The work item to execute on the STA thread. - /// A task that completes with the function result. - public Task RunAsync(Func func) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaComThread)); - if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited"); - - var tcs = new TaskCompletionSource(); - _workItems.Enqueue(new WorkItem - { - Execute = () => - { - try - { - tcs.TrySetResult(func()); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }, - Fault = ex => tcs.TrySetException(ex) - }); - - if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero)) - { - _pumpExited = true; - DrainAndFaultQueue(); - } - - return tcs.Task; - } - - private void ThreadEntry() - { - try - { - _nativeThreadId = GetCurrentThreadId(); - - MSG msg; - PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE); - - _ready.TrySetResult(true); - _lastLogTime = DateTime.UtcNow; - - Log.Debug("STA message pump entering loop"); - - while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0) - { - _totalMessages++; - - if (msg.message == WM_APP) - { - _appMessages++; - DrainQueue(); - } - else if (msg.message == WM_APP + 1) - { - DrainQueue(); - PostQuitMessage(0); - } - else - { - _dispatchedMessages++; - TranslateMessage(ref msg); - DispatchMessage(ref msg); - } - - LogPumpStatsIfDue(); - } - - Log.Information( - "STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})", - _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted); - } - catch (Exception ex) - { - Log.Error(ex, "STA COM thread crashed"); - _ready.TrySetException(ex); - } - finally - { - _pumpExited = true; - DrainAndFaultQueue(); - } - } - - private void DrainQueue() - { - while (_workItems.TryDequeue(out var workItem)) - { - _workItemsExecuted++; - try - { - workItem.Execute(); - } - catch (Exception ex) - { - Log.Error(ex, "Unhandled exception in STA work item"); - } - } - } - - private void DrainAndFaultQueue() - { - var faultException = new InvalidOperationException("STA COM thread pump has exited"); - while (_workItems.TryDequeue(out var workItem)) - { - try - { - workItem.Fault(faultException); - } - catch - { - // Faulting a TCS should not throw, but guard against it - } - } - } - - private void LogPumpStatsIfDue() - { - var now = DateTime.UtcNow; - if (now - _lastLogTime < PumpLogInterval) return; - Log.Debug( - "STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}", - _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count); - _lastLogTime = now; - } - - private sealed class WorkItem - { - public Action Execute { get; set; } - public Action Fault { get; set; } - } - - #region Win32 PInvoke - - [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.Host/OpcUa/AddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs deleted file mode 100644 index be1a286..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Builds the tag reference mappings from Galaxy hierarchy and attributes. - /// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004) - /// - public class AddressSpaceBuilder - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - /// - /// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes - /// nodes. - /// - /// The Galaxy object hierarchy returned by the repository. - /// The Galaxy attribute rows associated with the hierarchy. - /// An address-space model containing roots, variables, and tag-reference mappings. - public static AddressSpaceModel Build(List hierarchy, List attributes) - { - var model = new AddressSpaceModel(); - var objectMap = hierarchy.ToDictionary(h => h.GobjectId); - - var attrsByObject = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Build parent→children map - var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Find root objects (parent not in hierarchy) - var knownIds = new HashSet(hierarchy.Select(h => h.GobjectId)); - - foreach (var obj in hierarchy) - { - var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model); - - if (!knownIds.Contains(obj.ParentGobjectId)) - model.RootNodes.Add(nodeInfo); - } - - Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs", - model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count); - - return model; - } - - private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj, - Dictionary> attrsByObject, - Dictionary> childrenByParent, - AddressSpaceModel model) - { - var node = new NodeInfo - { - GobjectId = obj.GobjectId, - TagName = obj.TagName, - BrowseName = obj.BrowseName, - ParentGobjectId = obj.ParentGobjectId, - IsArea = obj.IsArea - }; - - if (!obj.IsArea) - model.ObjectCount++; - - if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs)) - foreach (var attr in attrs) - { - node.Attributes.Add(new AttributeNodeInfo - { - AttributeName = attr.AttributeName, - FullTagReference = attr.FullTagReference, - MxDataType = attr.MxDataType, - IsArray = attr.IsArray, - ArrayDimension = attr.ArrayDimension, - PrimitiveName = attr.PrimitiveName ?? "", - SecurityClassification = attr.SecurityClassification, - IsHistorized = attr.IsHistorized, - IsAlarm = attr.IsAlarm - }); - - model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference; - model.VariableCount++; - } - - return node; - } - - private static string GetNodeIdentifier(GalaxyAttributeInfo attr) - { - if (!attr.IsArray) - return attr.FullTagReference; - - return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal) - ? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2) - : attr.FullTagReference; - } - - /// - /// Node info for the address space tree. - /// - public class NodeInfo - { - /// - /// Gets or sets the Galaxy object identifier represented by this address-space node. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata. - /// - public string TagName { get; set; } = ""; - - /// - /// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node. - /// - public string BrowseName { get; set; } = ""; - - /// - /// Gets or sets the parent Galaxy object identifier used to assemble the tree. - /// - public int ParentGobjectId { get; set; } - - /// - /// Gets or sets a value indicating whether the node represents a Galaxy area folder. - /// - public bool IsArea { get; set; } - - /// - /// Gets or sets the attribute nodes published beneath this object. - /// - public List Attributes { get; set; } = new(); - - /// - /// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy. - /// - public List Children { get; set; } = new(); - } - - /// - /// Lightweight description of an attribute node that will become an OPC UA variable. - /// - public class AttributeNodeInfo - { - /// - /// Gets or sets the Galaxy attribute name published under the object. - /// - public string AttributeName { get; set; } = ""; - - /// - /// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions. - /// - public string FullTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy data type code used to pick the OPC UA variable type. - /// - public int MxDataType { get; set; } - - /// - /// Gets or sets a value indicating whether the attribute is modeled as an array. - /// - public bool IsArray { get; set; } - - /// - /// Gets or sets the declared array length when the attribute is a fixed-size array. - /// - public int? ArrayDimension { get; set; } - - /// - /// Gets or sets the primitive name that groups the attribute under a sub-object node. - /// Empty for root-level attributes. - /// - public string PrimitiveName { get; set; } = ""; - - /// - /// Gets or sets the Galaxy security classification that determines OPC UA write access. - /// - public int SecurityClassification { get; set; } = 1; - - /// - /// Gets or sets a value indicating whether the attribute is historized. - /// - public bool IsHistorized { get; set; } - - /// - /// Gets or sets a value indicating whether the attribute is an alarm. - /// - public bool IsAlarm { get; set; } - } - - /// - /// Result of building the address space model. - /// - public class AddressSpaceModel - { - /// - /// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace. - /// - public List RootNodes { get; set; } = new(); - - /// - /// Gets or sets the mapping from OPC UA node identifiers to runtime tag references. - /// - public Dictionary NodeIdToTagReference { get; set; } = - new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets or sets the number of non-area Galaxy objects included in the model. - /// - public int ObjectCount { get; set; } - - /// - /// Gets or sets the number of variable nodes created from Galaxy attributes. - /// - public int VariableCount { get; set; } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs deleted file mode 100644 index d91167a..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceDiff.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes. - /// - public static class AddressSpaceDiff - { - /// - /// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference. - /// - /// The previously published Galaxy object hierarchy snapshot. - /// The previously published Galaxy attribute snapshot keyed to the old hierarchy. - /// The latest Galaxy object hierarchy snapshot pulled from the repository. - /// The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace. - public static HashSet FindChangedGobjectIds( - List oldHierarchy, List oldAttributes, - List newHierarchy, List newAttributes) - { - var changed = new HashSet(); - - var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId); - var newObjects = newHierarchy.ToDictionary(h => h.GobjectId); - - // Added objects - foreach (var id in newObjects.Keys) - if (!oldObjects.ContainsKey(id)) - changed.Add(id); - - // Removed objects - foreach (var id in oldObjects.Keys) - if (!newObjects.ContainsKey(id)) - changed.Add(id); - - // Modified objects - foreach (var kvp in newObjects) - if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value)) - changed.Add(kvp.Key); - - // Attribute changes — group by gobject_id and compare - var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // All gobject_ids that have attributes in either old or new - var allAttrGobjectIds = new HashSet(oldAttrsByObj.Keys); - allAttrGobjectIds.UnionWith(newAttrsByObj.Keys); - - foreach (var id in allAttrGobjectIds) - { - if (changed.Contains(id)) - continue; - - oldAttrsByObj.TryGetValue(id, out var oldAttrs); - newAttrsByObj.TryGetValue(id, out var newAttrs); - - if (!AttributeSetsEqual(oldAttrs, newAttrs)) - changed.Add(id); - } - - return changed; - } - - /// - /// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy. - /// - /// The root Galaxy objects that were detected as changed between snapshots. - /// The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt. - public static HashSet ExpandToSubtrees(HashSet changed, List hierarchy) - { - var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId) - .ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList()); - - var expanded = new HashSet(changed); - var queue = new Queue(changed); - - while (queue.Count > 0) - { - var id = queue.Dequeue(); - if (childrenByParent.TryGetValue(id, out var children)) - foreach (var childId in children) - if (expanded.Add(childId)) - queue.Enqueue(childId); - } - - return expanded; - } - - private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b) - { - return a.TagName == b.TagName - && a.BrowseName == b.BrowseName - && a.ContainedName == b.ContainedName - && a.ParentGobjectId == b.ParentGobjectId - && a.IsArea == b.IsArea; - } - - private static bool AttributeSetsEqual(List? a, List? b) - { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - if (a.Count != b.Count) return false; - - // Sort by a stable key and compare pairwise - var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList(); - var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList(); - - for (var i = 0; i < sortedA.Count; i++) - if (!AttributesEqual(sortedA[i], sortedB[i])) - return false; - - return true; - } - - private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b) - { - return a.AttributeName == b.AttributeName - && a.FullTagReference == b.FullTagReference - && a.MxDataType == b.MxDataType - && a.IsArray == b.IsArray - && a.ArrayDimension == b.ArrayDimension - && a.PrimitiveName == b.PrimitiveName - && a.SecurityClassification == b.SecurityClassification - && a.IsHistorized == b.IsHistorized - && a.IsAlarm == b.IsAlarm; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs deleted file mode 100644 index 06b02f9..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/DataValueConverter.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using Opc.Ua; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007) - /// - public static class DataValueConverter - { - /// - /// Converts a bridge VTQ snapshot into an OPC UA data value. - /// - /// The VTQ snapshot to convert. - /// An OPC UA data value suitable for reads and subscriptions. - public static DataValue FromVtq(Vtq vtq) - { - var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality)); - - var dataValue = new DataValue - { - Value = ConvertToOpcUaValue(vtq.Value), - StatusCode = statusCode, - SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc - ? vtq.Timestamp - : vtq.Timestamp.ToUniversalTime(), - ServerTimestamp = DateTime.UtcNow - }; - - return dataValue; - } - - /// - /// Converts an OPC UA data value back into a bridge VTQ snapshot. - /// - /// The OPC UA data value to convert. - /// A VTQ snapshot containing the converted value, timestamp, and derived quality. - public static Vtq ToVtq(DataValue dataValue) - { - var quality = MapStatusCodeToQuality(dataValue.StatusCode); - var timestamp = dataValue.SourceTimestamp != DateTime.MinValue - ? dataValue.SourceTimestamp - : DateTime.UtcNow; - - return new Vtq(dataValue.Value, timestamp, quality); - } - - private static object? ConvertToOpcUaValue(object? value) - { - if (value == null) return null; - - return value switch - { - bool _ => value, - int _ => value, - float _ => value, - double _ => value, - string _ => value, - DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(), - TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds - short s => (int)s, - long l => l, - byte b => (int)b, - bool[] _ => value, - int[] _ => value, - float[] _ => value, - double[] _ => value, - string[] _ => value, - DateTime[] _ => value, - _ => value.ToString() - }; - } - - private static Quality MapStatusCodeToQuality(StatusCode statusCode) - { - var code = statusCode.Code; - if (StatusCode.IsGood(statusCode)) return Quality.Good; - if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain; - - return code switch - { - StatusCodes.BadNotConnected => Quality.BadNotConnected, - StatusCodes.BadCommunicationError => Quality.BadCommFailure, - StatusCodes.BadConfigurationError => Quality.BadConfigError, - StatusCodes.BadOutOfService => Quality.BadOutOfService, - StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData, - _ => Quality.Bad - }; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs deleted file mode 100644 index 4e675e0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs +++ /dev/null @@ -1,2924 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Opc.Ua; -using Opc.Ua.Server; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Historian; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Host.Utilities; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data. - /// (OPC-002 through OPC-013) - /// - public class LmxNodeManager : CustomNodeManager2 - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly Dictionary _alarmAckedTags = new(StringComparer.OrdinalIgnoreCase); - private readonly NodeId? _alarmAckRoleId; - - // Alarm tracking: maps InAlarm tag reference → alarm source info - private readonly Dictionary _alarmInAlarmTags = new(StringComparer.OrdinalIgnoreCase); - // Reverse lookups: priority/description tag reference → alarm info for cache updates - private readonly Dictionary _alarmPriorityTags = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _alarmDescTags = new(StringComparer.OrdinalIgnoreCase); - private readonly bool _alarmTrackingEnabled; - private readonly AlarmObjectFilter? _alarmObjectFilter; - private int _alarmFilterIncludedObjectCount; - private readonly bool _anonymousCanWrite; - - // Host → list of OPC UA variable nodes transitively hosted by that host. Populated during - // BuildAddressSpace by walking each variable's owning object's hosted_by_gobject_id chain - // up to the nearest $WinPlatform or $AppEngine. A variable that lives under a nested host - // (e.g. a user object under an Engine under a Platform) appears in BOTH the Engine's and - // the Platform's list. Used by MarkHostVariablesBadQuality / ClearHostVariablesBadQuality - // when the galaxy runtime probe reports a host transition. - private readonly Dictionary> _hostedVariables = - new Dictionary>(); - - // Tag reference → list of owning host gobject_ids (typically Engine + Platform). Populated - // alongside _hostedVariables during BuildAddressSpace. Used by the Read path to short-circuit - // on-demand reads of tags under a Stopped runtime host — preventing MxAccess from returning - // stale "Good" cached values. Multiple tag refs on the same Galaxy object share the same - // host-id list by reference (safe because the list is read-only after build). - private readonly Dictionary> _hostIdsByTagRef = - new Dictionary>(StringComparer.OrdinalIgnoreCase); - - // Runtime status probe manager — null when MxAccessConfiguration.RuntimeStatusProbesEnabled - // is false. Built at construction time and synced to the hierarchy on every BuildAddressSpace. - private readonly GalaxyRuntimeProbeManager? _galaxyRuntimeProbeManager; - - // Queue of host runtime state transitions deferred from the probe callback (which runs on - // the MxAccess STA thread) to the dispatch thread, where the node manager Lock can be taken - // safely. Enqueue → signal dispatch → dispatch thread drains and calls Mark/Clear under Lock. - // Required because invoking Mark/Clear directly from the STA callback deadlocks against any - // worker thread currently inside Read waiting for an MxAccess round-trip. - private readonly ConcurrentQueue<(int GobjectId, bool Stopped)> _pendingHostStateChanges = - new ConcurrentQueue<(int, bool)>(); - - // Synthetic $-prefixed OPC UA child variables exposed under each $WinPlatform / $AppEngine - // object so clients can subscribe to runtime state changes without polling the dashboard. - // Populated during BuildAddressSpace and updated from the dispatch-thread queue drain - // alongside Mark/Clear, using the same deadlock-safe path. - private readonly Dictionary _runtimeStatusNodes = - new Dictionary(); - - private sealed class HostRuntimeStatusNodes - { - public BaseDataVariableState RuntimeState = null!; - public BaseDataVariableState LastCallbackTime = null!; - public BaseDataVariableState LastScanState = null!; - public BaseDataVariableState LastStateChangeTime = null!; - public BaseDataVariableState FailureCount = null!; - public BaseDataVariableState LastError = null!; - } - private readonly AutoResetEvent _dataChangeSignal = new(false); - private readonly Dictionary> _gobjectToTagRefs = new(); - private readonly HistoryContinuationPointManager _historyContinuations = new(); - private readonly IHistorianDataSource? _historianDataSource; - private readonly PerformanceMetrics _metrics; - - private readonly IMxAccessClient _mxAccessClient; - private readonly string _namespaceUri; - - // NodeId → full_tag_reference for read/write resolution - private readonly Dictionary _nodeIdToTagReference = new(StringComparer.OrdinalIgnoreCase); - - // Incremental sync: persistent node map and reverse lookup - private readonly Dictionary _nodeMap = new(); - - // Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock - private readonly ConcurrentDictionary _pendingDataChanges = new(StringComparer.OrdinalIgnoreCase); - - // Ref-counted MXAccess subscriptions - private readonly Dictionary _subscriptionRefCounts = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _tagMetadata = new(StringComparer.OrdinalIgnoreCase); - - private readonly Dictionary _tagToVariableNode = - new(StringComparer.OrdinalIgnoreCase); - - private readonly NodeId? _writeConfigureRoleId; - private readonly NodeId? _writeOperateRoleId; - private readonly NodeId? _writeTuneRoleId; - private readonly TimeSpan _mxAccessRequestTimeout; - private readonly TimeSpan _historianRequestTimeout; - private long _dispatchCycleCount; - private long _suppressedUpdatesCount; - private volatile bool _dispatchDisposed; - private volatile bool _dispatchRunning; - private Thread? _dispatchThread; - - private IDictionary>? _externalReferences; - private List? _lastAttributes; - private List? _lastHierarchy; - private DateTime _lastMetricsReportTime = DateTime.UtcNow; - private long _lastReportedMxChangeEvents; - private long _totalDispatchBatchSize; - - // Dispatch queue metrics - private long _totalMxChangeEvents; - - // Alarm instrumentation counters - private long _alarmTransitionCount; - private long _alarmAckEventCount; - private long _alarmAckWriteFailures; - - // Background subscribe tracking: every fire-and-forget SubscribeAsync for alarm auto-subscribe - // and transferred-subscription restore is registered here so shutdown can drain pending work - // with a bounded timeout, and so tests can observe pending count without races. - private readonly ConcurrentDictionary _pendingBackgroundSubscribes = - new ConcurrentDictionary(); - private long _backgroundSubscribeCounter; - - /// - /// Initializes a new node manager for the Galaxy-backed OPC UA namespace. - /// - /// The hosting OPC UA server internals. - /// The OPC UA application configuration for the host. - /// The namespace URI that identifies the Galaxy model to clients. - /// The runtime client used to service reads, writes, and subscriptions. - /// The metrics collector used to track node manager activity. - /// The optional historian adapter used to satisfy OPC UA history read requests. - /// Enables alarm-condition state generation for Galaxy attributes modeled as alarms. - /// Optional template-based object filter. When supplied and enabled, only Galaxy - /// objects whose template derivation chain matches a pattern (and their descendants) contribute alarm conditions. - /// A or disabled filter preserves the current unfiltered behavior. - public LmxNodeManager( - IServerInternal server, - ApplicationConfiguration configuration, - string namespaceUri, - IMxAccessClient mxAccessClient, - PerformanceMetrics metrics, - IHistorianDataSource? historianDataSource = null, - bool alarmTrackingEnabled = false, - bool anonymousCanWrite = true, - NodeId? writeOperateRoleId = null, - NodeId? writeTuneRoleId = null, - NodeId? writeConfigureRoleId = null, - NodeId? alarmAckRoleId = null, - AlarmObjectFilter? alarmObjectFilter = null, - bool runtimeStatusProbesEnabled = false, - int runtimeStatusUnknownTimeoutSeconds = 15, - int mxAccessRequestTimeoutSeconds = 30, - int historianRequestTimeoutSeconds = 60) - : base(server, configuration, namespaceUri) - { - _namespaceUri = namespaceUri; - _mxAccessClient = mxAccessClient; - _metrics = metrics; - _historianDataSource = historianDataSource; - _alarmTrackingEnabled = alarmTrackingEnabled; - _alarmObjectFilter = alarmObjectFilter; - _anonymousCanWrite = anonymousCanWrite; - _writeOperateRoleId = writeOperateRoleId; - _writeTuneRoleId = writeTuneRoleId; - _writeConfigureRoleId = writeConfigureRoleId; - _alarmAckRoleId = alarmAckRoleId; - _mxAccessRequestTimeout = TimeSpan.FromSeconds(Math.Max(1, mxAccessRequestTimeoutSeconds)); - _historianRequestTimeout = TimeSpan.FromSeconds(Math.Max(1, historianRequestTimeoutSeconds)); - - if (runtimeStatusProbesEnabled) - { - // Probe transition callbacks are deferred through a concurrent queue onto the - // dispatch thread — they cannot run synchronously from the STA callback thread - // because MarkHostVariablesBadQuality needs the node manager Lock, which may be - // held by a worker thread waiting on an MxAccess round-trip. - _galaxyRuntimeProbeManager = new GalaxyRuntimeProbeManager( - _mxAccessClient, - runtimeStatusUnknownTimeoutSeconds, - gobjectId => - { - _pendingHostStateChanges.Enqueue((gobjectId, true)); - try { _dataChangeSignal.Set(); } catch (ObjectDisposedException) { } - }, - gobjectId => - { - _pendingHostStateChanges.Enqueue((gobjectId, false)); - try { _dataChangeSignal.Set(); } catch (ObjectDisposedException) { } - }); - } - - // Wire up data change delivery - _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; - - // Start background dispatch thread - StartDispatchThread(); - } - - /// - /// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O. - /// - public IReadOnlyDictionary NodeIdToTagReference => _nodeIdToTagReference; - - /// - /// Gets the number of variable nodes currently published from Galaxy attributes. - /// - public int VariableNodeCount { get; private set; } - - /// - /// Gets the number of non-area object nodes currently published from the Galaxy hierarchy. - /// - public int ObjectNodeCount { get; private set; } - - /// - /// Gets the total number of MXAccess data change events received since startup. - /// - public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents); - - /// - /// Gets the number of items currently waiting in the dispatch queue. - /// - public int PendingDataChangeCount => _pendingDataChanges.Count; - - /// - /// Gets the most recently computed MXAccess data change events per second. - /// - public double MxChangeEventsPerSecond { get; private set; } - - /// - /// Gets the most recently computed average dispatch batch size (proxy for queue depth under load). - /// - public double AverageDispatchBatchSize { get; private set; } - - /// - /// Gets a value indicating whether alarm condition tracking is enabled for this node manager. - /// - public bool AlarmTrackingEnabled => _alarmTrackingEnabled; - - /// - /// Gets a value indicating whether the template-based alarm object filter is enabled. - /// - public bool AlarmFilterEnabled => _alarmObjectFilter?.Enabled ?? false; - - /// - /// Gets the number of compiled alarm filter patterns. - /// - public int AlarmFilterPatternCount => _alarmObjectFilter?.PatternCount ?? 0; - - /// - /// Gets the number of Galaxy objects included by the alarm filter during the most recent address-space build. - /// - public int AlarmFilterIncludedObjectCount => _alarmFilterIncludedObjectCount; - - /// - /// Gets the raw alarm filter patterns exactly as configured, for display on the status dashboard. - /// Returns an empty list when no filter is active. - /// - public IReadOnlyList AlarmFilterPatterns => - _alarmObjectFilter?.RawPatterns ?? Array.Empty(); - - /// - /// Gets a snapshot of the runtime host states (Platforms + AppEngines). Returns an empty - /// list when runtime status probing is disabled. The snapshot respects MxAccess transport - /// state — when the client is disconnected, every entry is returned as - /// . - /// - public IReadOnlyList RuntimeStatuses => - _galaxyRuntimeProbeManager?.GetSnapshot() ?? (IReadOnlyList)Array.Empty(); - - /// - /// Gets the number of bridge-owned runtime status probe subscriptions. Surfaced on the - /// dashboard Subscriptions panel to distinguish probe overhead from client subscriptions. - /// - public int ActiveRuntimeProbeCount => _galaxyRuntimeProbeManager?.ActiveProbeCount ?? 0; - - /// - /// Gets the runtime historian health snapshot, or when the historian - /// plugin is not loaded. Surfaced on the status dashboard so operators can detect query - /// failures that the load-time plugin status cannot catch. - /// - public HistorianHealthSnapshot? HistorianHealth => _historianDataSource?.GetHealthSnapshot(); - - /// - /// Gets the number of distinct alarm conditions currently tracked (one per alarm attribute). - /// - public int AlarmConditionCount => _alarmInAlarmTags.Count; - - /// - /// Gets the number of alarms currently in the InAlarm=true state. - /// - public int ActiveAlarmCount => CountActiveAlarms(); - - /// - /// Gets the total number of InAlarm transition events observed in the dispatch loop since startup. - /// - public long AlarmTransitionCount => Interlocked.Read(ref _alarmTransitionCount); - - /// - /// Gets the total number of alarm acknowledgement transition events observed since startup. - /// - public long AlarmAckEventCount => Interlocked.Read(ref _alarmAckEventCount); - - /// - /// Gets the total number of MXAccess AckMsg writes that failed while processing alarm acknowledges. - /// - public long AlarmAckWriteFailures => Interlocked.Read(ref _alarmAckWriteFailures); - - private int CountActiveAlarms() - { - var count = 0; - lock (Lock) - { - foreach (var info in _alarmInAlarmTags.Values) - if (info.LastInAlarm) count++; - } - return count; - } - - /// - public override void CreateAddressSpace(IDictionary> externalReferences) - { - lock (Lock) - { - _externalReferences = externalReferences; - base.CreateAddressSpace(externalReferences); - } - } - - /// - /// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003) - /// - /// The Galaxy object hierarchy that defines folders and objects in the namespace. - /// The Galaxy attributes that become OPC UA variable nodes. - public void BuildAddressSpace(List hierarchy, List attributes) - { - lock (Lock) - { - _nodeIdToTagReference.Clear(); - _tagToVariableNode.Clear(); - _tagMetadata.Clear(); - _alarmInAlarmTags.Clear(); - _alarmAckedTags.Clear(); - _alarmPriorityTags.Clear(); - _alarmDescTags.Clear(); - _nodeMap.Clear(); - _gobjectToTagRefs.Clear(); - _hostedVariables.Clear(); - _hostIdsByTagRef.Clear(); - _runtimeStatusNodes.Clear(); - VariableNodeCount = 0; - ObjectNodeCount = 0; - - // Topological sort: ensure parents appear before children regardless of input order - var sorted = TopologicalSort(hierarchy); - - // Build lookup: gobject_id → list of attributes - var attrsByObject = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Root folder — enable events so alarm events propagate to clients subscribed at root - var rootFolder = CreateFolder(null, "ZB", "ZB"); - rootFolder.NodeId = new NodeId("ZB", NamespaceIndex); - rootFolder.EventNotifier = EventNotifiers.SubscribeToEvents; - rootFolder.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder); - - AddPredefinedNode(SystemContext, rootFolder); - - // Add reverse reference from Objects folder → ZB root. - // BuildAddressSpace runs after CreateAddressSpace completes, so the - // externalReferences dict has already been consumed by the core node manager. - // Use MasterNodeManager.AddReferences to route the reference correctly. - Server.NodeManager.AddReferences(ObjectIds.ObjectsFolder, new List - { - new NodeStateReference(ReferenceTypeIds.Organizes, false, rootFolder.NodeId) - }); - - // Create nodes for each object in hierarchy - foreach (var obj in sorted) - { - NodeState parentNode; - if (_nodeMap.TryGetValue(obj.ParentGobjectId, out var p)) - parentNode = p; - else - parentNode = rootFolder; - - NodeState node; - if (obj.IsArea) - { - // Areas → FolderType + Organizes reference - var folder = CreateFolder(parentNode, obj.BrowseName, obj.BrowseName); - folder.NodeId = new NodeId(obj.TagName, NamespaceIndex); - node = folder; - } - else - { - // Non-areas → BaseObjectType + HasComponent reference - var objNode = CreateObject(parentNode, obj.BrowseName, obj.BrowseName); - objNode.NodeId = new NodeId(obj.TagName, NamespaceIndex); - node = objNode; - ObjectNodeCount++; - } - - AddPredefinedNode(SystemContext, node); - _nodeMap[obj.GobjectId] = node; - - // Attach bridge-owned $RuntimeState / $LastCallbackTime / ... synthetic child - // variables so OPC UA clients can subscribe to host state changes without - // polling the dashboard. Only $WinPlatform (1) and $AppEngine (3) get them. - if (_galaxyRuntimeProbeManager != null - && (obj.CategoryId == 1 || obj.CategoryId == 3) - && node is BaseObjectState hostObj) - { - _runtimeStatusNodes[obj.GobjectId] = - CreateHostRuntimeStatusNodes(hostObj, obj.TagName); - } - - // Create variable nodes for this object's attributes - if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) - { - // Group by primitive_name: empty = direct child, non-empty = sub-object - var byPrimitive = objAttrs - .GroupBy(a => a.PrimitiveName ?? "") - .OrderBy(g => g.Key); - - // Collect primitive group names so we know which direct attributes have children - var primitiveGroupNames = new HashSet( - byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)), - StringComparer.OrdinalIgnoreCase); - - // Track variable nodes created for direct attributes that also have primitive children - var variableNodes = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - // First pass: create direct (root-level) attribute variables - var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key)); - if (directGroup != null) - foreach (var attr in directGroup) - { - var variable = CreateAttributeVariable(node, attr); - if (primitiveGroupNames.Contains(attr.AttributeName)) - variableNodes[attr.AttributeName] = variable; - } - - // Second pass: add primitive child attributes under the matching variable node - foreach (var group in byPrimitive) - { - if (string.IsNullOrEmpty(group.Key)) - continue; - - NodeState parentForAttrs; - if (variableNodes.TryGetValue(group.Key, out var existingVariable)) - { - // Merge: use the existing variable node as parent - parentForAttrs = existingVariable; - } - else - { - // No matching dynamic attribute — create an object node - var primNode = CreateObject(node, group.Key, group.Key); - primNode.NodeId = new NodeId(obj.TagName + "." + group.Key, NamespaceIndex); - AddPredefinedNode(SystemContext, primNode); - parentForAttrs = primNode; - } - - foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr); - } - } - } - - // Build alarm tracking: create AlarmConditionState for each alarm attribute - if (_alarmTrackingEnabled) - { - var includedIds = ResolveAlarmFilterIncludedIds(sorted); - foreach (var obj in sorted) - { - if (obj.IsArea) continue; - if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue; - if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; - - var hasAlarms = false; - var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)) - .ToList(); - foreach (var alarmAttr in alarmAttrs) - { - var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm"; - if (!_tagToVariableNode.ContainsKey(inAlarmTagRef)) - continue; - - var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]") - ? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2) - : alarmAttr.FullTagReference; - - // Find the source variable node for the alarm - _tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable); - var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex); - - // Create AlarmConditionState attached to the source variable - var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex); - var condition = new AlarmConditionState(sourceVariable); - condition.Create(SystemContext, conditionNodeId, - new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex), - new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), - true); - condition.SourceNode.Value = sourceNodeId; - condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); - condition.ConditionName.Value = alarmAttr.AttributeName; - condition.AutoReportStateChanges = true; - - // Set initial state: enabled, inactive, acknowledged - condition.SetEnableState(SystemContext, true); - condition.SetActiveState(SystemContext, false); - condition.SetAcknowledgedState(SystemContext, true); - condition.SetSeverity(SystemContext, EventSeverity.Medium); - condition.Retain.Value = false; - condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e); - condition.OnAcknowledge = OnAlarmAcknowledge; - condition.OnConfirm = OnAlarmConfirm; - condition.OnAddComment = OnAlarmAddComment; - condition.OnEnableDisable = OnAlarmEnableDisable; - condition.OnShelve = OnAlarmShelve; - condition.OnTimedUnshelve = OnAlarmTimedUnshelve; - - // Add HasCondition reference from source to condition - if (sourceVariable != null) - { - sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId); - condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId); - } - - AddPredefinedNode(SystemContext, condition); - - var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); - var alarmInfo = new AlarmInfo - { - SourceTagReference = alarmAttr.FullTagReference, - SourceNodeId = sourceNodeId, - SourceName = alarmAttr.AttributeName, - ConditionNode = condition, - PriorityTagReference = baseTagRef + ".Priority", - DescAttrNameTagReference = baseTagRef + ".DescAttrName", - AckedTagReference = baseTagRef + ".Acked", - AckMsgTagReference = baseTagRef + ".AckMsg" - }; - _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; - _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; - if (!string.IsNullOrEmpty(alarmInfo.PriorityTagReference)) - _alarmPriorityTags[alarmInfo.PriorityTagReference] = alarmInfo; - if (!string.IsNullOrEmpty(alarmInfo.DescAttrNameTagReference)) - _alarmDescTags[alarmInfo.DescAttrNameTagReference] = alarmInfo; - hasAlarms = true; - } - - // Enable EventNotifier on this node and all ancestors so alarm events propagate - if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode)) - EnableEventNotifierUpChain(objNode); - } - } - - // Auto-subscribe to InAlarm tags so we detect alarm transitions - if (_alarmTrackingEnabled) - SubscribeAlarmTags(); - - BuildHostedVariablesMap(hierarchy); - - // Sync the galaxy runtime probe set against the rebuilt hierarchy. This runs - // synchronously on the calling thread and issues AdviseSupervisory per host — - // expected 500ms-1s additional startup latency for a large multi-host galaxy. - // Bounded by _mxAccessRequestTimeout so a hung probe sync cannot park the address - // space rebuild indefinitely; on timeout we log a warning and continue with the - // partial probe set (probe sync is advisory, not required for address space correctness). - if (_galaxyRuntimeProbeManager != null) - { - try - { - SyncOverAsync.WaitSync( - _galaxyRuntimeProbeManager.SyncAsync(hierarchy), - _mxAccessRequestTimeout, - "GalaxyRuntimeProbeManager.SyncAsync"); - } - catch (TimeoutException ex) - { - Log.Warning(ex, "Runtime probe sync exceeded {Timeout}s; continuing with partial probe set", - _mxAccessRequestTimeout.TotalSeconds); - } - } - - _lastHierarchy = new List(hierarchy); - _lastAttributes = new List(attributes); - - Log.Information( - "Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags, {Hosts} runtime hosts", - ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count, - _hostedVariables.Count); - } - } - - /// - /// Resolves the alarm object filter against the given hierarchy, updates the published include count, - /// emits a one-line summary log when the filter is active, and warns about patterns that matched nothing. - /// Returns when no filter is configured so the alarm loop continues unfiltered. - /// - private HashSet? ResolveAlarmFilterIncludedIds(IReadOnlyList sorted) - { - if (_alarmObjectFilter == null || !_alarmObjectFilter.Enabled) - { - _alarmFilterIncludedObjectCount = 0; - return null; - } - - var includedIds = _alarmObjectFilter.ResolveIncludedObjects(sorted); - _alarmFilterIncludedObjectCount = includedIds?.Count ?? 0; - - Log.Information( - "Alarm filter: {IncludedCount} of {TotalCount} objects included ({PatternCount} pattern(s))", - _alarmFilterIncludedObjectCount, sorted.Count, _alarmObjectFilter.PatternCount); - - foreach (var unmatched in _alarmObjectFilter.UnmatchedPatterns) - Log.Warning("Alarm filter pattern matched zero objects: {Pattern}", unmatched); - - return includedIds; - } - - /// - /// Builds the _hostedVariables dictionary from the completed address space. For each - /// Galaxy object, walks its HostedByGobjectId chain up to the nearest $WinPlatform - /// or $AppEngine and appends every variable the object owns to that host's list. An - /// object under an Engine under a Platform appears in BOTH lists so stopping the Platform - /// invalidates every descendant Engine's variables as well. - /// - private void BuildHostedVariablesMap(List hierarchy) - { - _hostedVariables.Clear(); - _hostIdsByTagRef.Clear(); - if (hierarchy == null || hierarchy.Count == 0) - return; - - var byId = new Dictionary(hierarchy.Count); - foreach (var obj in hierarchy) - byId[obj.GobjectId] = obj; - - foreach (var obj in hierarchy) - { - if (!_gobjectToTagRefs.TryGetValue(obj.GobjectId, out var tagRefs) || tagRefs.Count == 0) - continue; - - // Collect every variable node owned by this object from the tag→variable map. - var ownedVariables = new List(tagRefs.Count); - foreach (var tagRef in tagRefs) - if (_tagToVariableNode.TryGetValue(tagRef, out var v)) - ownedVariables.Add(v); - - if (ownedVariables.Count == 0) - continue; - - // Walk HostedByGobjectId up the chain, collecting every Platform/Engine encountered. - // Visited set defends against cycles in misconfigured galaxies. Every tag ref owned - // by this object shares the same ancestorHosts list by reference. - var ancestorHosts = new List(); - var visited = new HashSet(); - var cursor = obj; - var depth = 0; - while (cursor != null && depth < 32 && visited.Add(cursor.GobjectId)) - { - if (cursor.CategoryId == 1 || cursor.CategoryId == 3) - ancestorHosts.Add(cursor.GobjectId); - - if (cursor.HostedByGobjectId == 0 || - !byId.TryGetValue(cursor.HostedByGobjectId, out var next)) - break; - cursor = next; - depth++; - } - - if (ancestorHosts.Count == 0) - continue; - - // Append this object's variables to each host's hosted-variables list. - foreach (var hostId in ancestorHosts) - { - if (!_hostedVariables.TryGetValue(hostId, out var list)) - { - list = new List(); - _hostedVariables[hostId] = list; - } - list.AddRange(ownedVariables); - } - - // Register reverse lookup for the Read-path short-circuit. - foreach (var tagRef in tagRefs) - _hostIdsByTagRef[tagRef] = ancestorHosts; - } - } - - /// - /// Flips every OPC UA variable hosted by the given Galaxy runtime object (Platform or - /// AppEngine) to . Invoked by the runtime probe - /// manager's Running → Stopped callback. Safe to call with an unknown gobject id — no-op. - /// - /// The runtime host's gobject_id. - public void MarkHostVariablesBadQuality(int gobjectId) - { - List? variables; - lock (Lock) - { - if (!_hostedVariables.TryGetValue(gobjectId, out variables)) - return; - - var now = DateTime.UtcNow; - foreach (var variable in variables) - { - variable.StatusCode = StatusCodes.BadOutOfService; - variable.Timestamp = now; - variable.ClearChangeMasks(SystemContext, false); - } - } - - Log.Information( - "Marked {Count} variable(s) BadOutOfService for stopped host gobject_id={GobjectId}", - variables.Count, gobjectId); - } - - /// - /// Creates the six $-prefixed synthetic child variables on a host object so OPC UA - /// clients can subscribe to runtime state changes without polling the dashboard. All - /// nodes are read-only and their values are refreshed by - /// from the dispatch-thread queue drain whenever the host transitions. - /// - private HostRuntimeStatusNodes CreateHostRuntimeStatusNodes(BaseObjectState hostNode, string hostTagName) - { - var nodes = new HostRuntimeStatusNodes - { - RuntimeState = CreateSyntheticVariable(hostNode, hostTagName, "$RuntimeState", DataTypeIds.String, "Unknown"), - LastCallbackTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastCallbackTime", DataTypeIds.DateTime, DateTime.MinValue), - LastScanState = CreateSyntheticVariable(hostNode, hostTagName, "$LastScanState", DataTypeIds.Boolean, false), - LastStateChangeTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastStateChangeTime", DataTypeIds.DateTime, DateTime.MinValue), - FailureCount = CreateSyntheticVariable(hostNode, hostTagName, "$FailureCount", DataTypeIds.Int64, 0L), - LastError = CreateSyntheticVariable(hostNode, hostTagName, "$LastError", DataTypeIds.String, "") - }; - return nodes; - } - - private BaseDataVariableState CreateSyntheticVariable( - BaseObjectState parent, string parentTagName, string browseName, NodeId dataType, object initialValue) - { - var v = CreateVariable(parent, browseName, browseName, dataType, ValueRanks.Scalar); - v.NodeId = new NodeId(parentTagName + "." + browseName, NamespaceIndex); - v.Value = initialValue; - v.StatusCode = StatusCodes.Good; - v.Timestamp = DateTime.UtcNow; - v.AccessLevel = AccessLevels.CurrentRead; - v.UserAccessLevel = AccessLevels.CurrentRead; - AddPredefinedNode(SystemContext, v); - return v; - } - - /// - /// Refreshes the six synthetic child variables on a host from the probe manager's - /// current snapshot for that host. Called from the dispatch-thread queue drain after - /// Mark/Clear so the state values propagate to subscribed clients in the same publish - /// cycle. Takes the node manager internally. - /// - private void UpdateHostRuntimeStatusNodes(int gobjectId) - { - if (_galaxyRuntimeProbeManager == null) - return; - - HostRuntimeStatusNodes? nodes; - lock (Lock) - { - if (!_runtimeStatusNodes.TryGetValue(gobjectId, out nodes)) - return; - } - - var status = _galaxyRuntimeProbeManager.GetHostStatus(gobjectId); - if (status == null) - return; - - lock (Lock) - { - var now = DateTime.UtcNow; - SetSynthetic(nodes.RuntimeState, status.State.ToString(), now); - SetSynthetic(nodes.LastCallbackTime, status.LastStateCallbackTime ?? DateTime.MinValue, now); - SetSynthetic(nodes.LastScanState, status.LastScanState ?? false, now); - SetSynthetic(nodes.LastStateChangeTime, status.LastStateChangeTime ?? DateTime.MinValue, now); - SetSynthetic(nodes.FailureCount, status.FailureCount, now); - SetSynthetic(nodes.LastError, status.LastError ?? "", now); - } - } - - private void SetSynthetic(BaseDataVariableState variable, object value, DateTime now) - { - variable.Value = value; - variable.StatusCode = StatusCodes.Good; - variable.Timestamp = now; - variable.ClearChangeMasks(SystemContext, false); - } - - /// - /// Resets every OPC UA variable hosted by the given Galaxy runtime object to - /// . Invoked by the runtime probe manager's Stopped → Running - /// callback. Values are left as-is; subsequent MxAccess on-change updates will refresh them - /// as tags change naturally. - /// - /// The runtime host's gobject_id. - public void ClearHostVariablesBadQuality(int gobjectId) - { - var clearedCount = 0; - var skippedCount = 0; - lock (Lock) - { - var now = DateTime.UtcNow; - // Iterate the full tag → host-list map so we can skip variables whose other - // ancestor hosts are still Stopped. Mass-clearing _hostedVariables[gobjectId] - // would wipe Bad status set by a concurrently-stopped sibling host (e.g. - // recovering DevPlatform must not clear variables that also live under a - // still-stopped DevAppEngine). - foreach (var kv in _hostIdsByTagRef) - { - var hostIds = kv.Value; - if (!hostIds.Contains(gobjectId)) - continue; - - var anotherStopped = false; - for (var i = 0; i < hostIds.Count; i++) - { - if (hostIds[i] == gobjectId) - continue; - if (_galaxyRuntimeProbeManager != null && - _galaxyRuntimeProbeManager.IsHostStopped(hostIds[i])) - { - anotherStopped = true; - break; - } - } - if (anotherStopped) - { - skippedCount++; - continue; - } - - if (_tagToVariableNode.TryGetValue(kv.Key, out var variable)) - { - variable.StatusCode = StatusCodes.Good; - variable.Timestamp = now; - variable.ClearChangeMasks(SystemContext, false); - clearedCount++; - } - } - } - - Log.Information( - "Cleared bad-quality override on {Count} variable(s) for recovered host gobject_id={GobjectId} (skipped {Skipped} with other stopped ancestors)", - clearedCount, gobjectId, skippedCount); - } - - private void SubscribeAlarmTags() - { - foreach (var kvp in _alarmInAlarmTags) - { - // Subscribe to InAlarm, Priority, and DescAttrName for each alarm - var tagsToSubscribe = new[] - { - kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference, - kvp.Value.AckedTagReference - }; - foreach (var tag in tagsToSubscribe) - { - if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag)) - continue; - TrackBackgroundSubscribe(tag, "alarm auto-subscribe"); - } - } - } - - /// - /// Issues a fire-and-forget SubscribeAsync for and registers - /// the resulting task so shutdown can drain pending work with a bounded timeout. The - /// continuation both removes the completed entry and logs faults with the supplied - /// . - /// - private void TrackBackgroundSubscribe(string tag, string context) - { - if (_dispatchDisposed) - return; - - var id = Interlocked.Increment(ref _backgroundSubscribeCounter); - var task = _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); - _pendingBackgroundSubscribes[id] = task; - task.ContinueWith(t => - { - _pendingBackgroundSubscribes.TryRemove(id, out _); - if (t.IsFaulted) - Log.Warning(t.Exception?.InnerException, "Background subscribe failed ({Context}) for {Tag}", - context, tag); - }, TaskContinuationOptions.ExecuteSynchronously); - } - - /// - /// Gets the number of background subscribe tasks currently in flight. Exposed for tests - /// and for the status dashboard subscription panel. - /// - internal int PendingBackgroundSubscribeCount => _pendingBackgroundSubscribes.Count; - - private ServiceResult OnAlarmAcknowledge( - ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) - { - if (!HasAlarmAckPermission(context)) - return new ServiceResult(StatusCodes.BadUserAccessDenied); - - var alarmInfo = _alarmInAlarmTags.Values - .FirstOrDefault(a => a.ConditionNode == condition); - if (alarmInfo == null) - return new ServiceResult(StatusCodes.BadNodeIdUnknown); - - using var scope = _metrics.BeginOperation("AlarmAcknowledge"); - try - { - var ackMessage = comment?.Text ?? ""; - _mxAccessClient.WriteAsync(alarmInfo.AckMsgTagReference, ackMessage) - .GetAwaiter().GetResult(); - Log.Information("Alarm acknowledge sent: {Source} (Message={AckMsg})", - alarmInfo.SourceName, ackMessage); - return ServiceResult.Good; - } - catch (Exception ex) - { - scope.SetSuccess(false); - Interlocked.Increment(ref _alarmAckWriteFailures); - Log.Warning(ex, "Failed to write AckMsg for {Source}", alarmInfo.SourceName); - return new ServiceResult(StatusCodes.BadInternalError); - } - } - - private ServiceResult OnAlarmConfirm( - ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) - { - Log.Information("Alarm confirmed: {Name} (Comment={Comment})", - condition.ConditionName?.Value, comment?.Text); - return ServiceResult.Good; - } - - private ServiceResult OnAlarmAddComment( - ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) - { - Log.Information("Alarm comment added: {Name} — {Comment}", - condition.ConditionName?.Value, comment?.Text); - return ServiceResult.Good; - } - - private ServiceResult OnAlarmEnableDisable( - ISystemContext context, ConditionState condition, bool enabling) - { - Log.Information("Alarm {Action}: {Name}", - enabling ? "ENABLED" : "DISABLED", condition.ConditionName?.Value); - return ServiceResult.Good; - } - - private ServiceResult OnAlarmShelve( - ISystemContext context, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime) - { - alarm.SetShelvingState(context, shelving, oneShot, shelvingTime); - Log.Information("Alarm {Action}: {Name} (OneShot={OneShot}, Time={Time}s)", - shelving ? "SHELVED" : "UNSHELVED", alarm.ConditionName?.Value, oneShot, - shelvingTime / 1000.0); - return ServiceResult.Good; - } - - private ServiceResult OnAlarmTimedUnshelve( - ISystemContext context, AlarmConditionState alarm) - { - alarm.SetShelvingState(context, false, false, 0); - Log.Information("Alarm timed unshelve: {Name}", alarm.ConditionName?.Value); - return ServiceResult.Good; - } - - private void ReportAlarmEvent(AlarmInfo info, bool active) - { - var condition = info.ConditionNode; - if (condition == null) - return; - - var severity = info.CachedSeverity; - var message = active - ? !string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}" - : $"Alarm cleared: {info.SourceName}"; - - // Set a new EventId so clients can reference this event for acknowledge - condition.EventId.Value = Guid.NewGuid().ToByteArray(); - - condition.SetActiveState(SystemContext, active); - condition.Message.Value = new LocalizedText("en", message); - condition.SetSeverity(SystemContext, (EventSeverity)severity); - - // Populate additional event fields - if (condition.LocalTime != null) - condition.LocalTime.Value = new TimeZoneDataType - { - Offset = (short)TimeZoneInfo.Local.BaseUtcOffset.TotalMinutes, - DaylightSavingInOffset = TimeZoneInfo.Local.IsDaylightSavingTime(DateTime.Now) - }; - if (condition.Quality != null) - condition.Quality.Value = StatusCodes.Good; - - // Retain while active or unacknowledged - condition.Retain.Value = active || condition.AckedState?.Id?.Value == false; - - // Reset acknowledged state when alarm activates - if (active) - condition.SetAcknowledgedState(SystemContext, false); - - // Walk up the notifier chain so events reach subscribers at any ancestor level - if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var sourceVar)) - ReportEventUpNotifierChain(sourceVar, condition); - - Log.Information("Alarm {State}: {Source} (Severity={Severity}, Message={Message})", - active ? "ACTIVE" : "CLEARED", info.SourceName, severity, message); - } - - /// - /// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010) - /// - /// The latest Galaxy object hierarchy to publish. - /// The latest Galaxy attributes to publish. - public void RebuildAddressSpace(List hierarchy, List attributes) - { - SyncAddressSpace(hierarchy, attributes); - } - - /// - /// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010) - /// - /// The latest Galaxy object hierarchy snapshot to compare against the currently published model. - /// The latest Galaxy attribute snapshot to compare against the currently published variables. - public void SyncAddressSpace(List hierarchy, List attributes) - { - var tagsToUnsubscribe = new List(); - var tagsToResubscribe = new List(); - - lock (Lock) - { - if (_lastHierarchy == null || _lastAttributes == null) - { - Log.Information("No previous state cached — performing full build"); - BuildAddressSpace(hierarchy, attributes); - return; - } - - var changedIds = AddressSpaceDiff.FindChangedGobjectIds( - _lastHierarchy, _lastAttributes, hierarchy, attributes); - - if (changedIds.Count == 0) - { - Log.Information("No address space changes detected"); - _lastHierarchy = hierarchy; - _lastAttributes = attributes; - return; - } - - // Expand to include child subtrees in both old and new hierarchy - changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, _lastHierarchy); - changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, hierarchy); - - Log.Information("Incremental sync: {Count} gobjects changed out of {Total}", - changedIds.Count, hierarchy.Count); - - // Snapshot subscriptions for changed tags before teardown - var affectedSubscriptions = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var id in changedIds) - if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs)) - foreach (var tagRef in tagRefs) - if (_subscriptionRefCounts.TryGetValue(tagRef, out var count)) - affectedSubscriptions[tagRef] = count; - - // Tear down changed subtrees (collects tags for deferred unsubscription) - TearDownGobjects(changedIds, tagsToUnsubscribe); - - // Rebuild changed subtrees from new data - var changedHierarchy = hierarchy.Where(h => changedIds.Contains(h.GobjectId)).ToList(); - var changedAttributes = attributes.Where(a => changedIds.Contains(a.GobjectId)).ToList(); - BuildSubtree(changedHierarchy, changedAttributes); - - // Restore subscription bookkeeping for surviving tags - foreach (var kvp in affectedSubscriptions) - { - if (!_tagToVariableNode.ContainsKey(kvp.Key)) - continue; - - _subscriptionRefCounts[kvp.Key] = kvp.Value; - tagsToResubscribe.Add(kvp.Key); - } - - _lastHierarchy = new List(hierarchy); - _lastAttributes = new List(attributes); - - Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms", - ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count); - } - - // Perform subscribe/unsubscribe I/O outside Lock so read/write/browse operations are not blocked - foreach (var tag in tagsToUnsubscribe) - try { _mxAccessClient.UnsubscribeAsync(tag).GetAwaiter().GetResult(); } - catch (Exception ex) { Log.Warning(ex, "Failed to unsubscribe {Tag} after sync", tag); } - - foreach (var tag in tagsToResubscribe) - try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }).GetAwaiter().GetResult(); } - catch (Exception ex) { Log.Warning(ex, "Failed to restore subscription for {Tag} after sync", tag); } - } - - private void TearDownGobjects(HashSet gobjectIds, List tagsToUnsubscribe) - { - foreach (var id in gobjectIds) - { - // Remove variable nodes and their tracking data - if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs)) - { - foreach (var tagRef in tagRefs.ToList()) - { - // Defer unsubscribe to outside lock - if (_subscriptionRefCounts.ContainsKey(tagRef)) - { - tagsToUnsubscribe.Add(tagRef); - _subscriptionRefCounts.Remove(tagRef); - } - - // Remove alarm tracking for this tag's InAlarm/Priority/DescAttrName - var alarmKeysToRemove = _alarmInAlarmTags - .Where(kvp => kvp.Value.SourceTagReference == tagRef) - .Select(kvp => kvp.Key) - .ToList(); - foreach (var alarmKey in alarmKeysToRemove) - { - var info = _alarmInAlarmTags[alarmKey]; - // Defer alarm tag unsubscription to outside lock - foreach (var alarmTag in new[] - { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference }) - if (!string.IsNullOrEmpty(alarmTag)) - tagsToUnsubscribe.Add(alarmTag); - - _alarmInAlarmTags.Remove(alarmKey); - if (!string.IsNullOrEmpty(info.PriorityTagReference)) - _alarmPriorityTags.Remove(info.PriorityTagReference); - if (!string.IsNullOrEmpty(info.DescAttrNameTagReference)) - _alarmDescTags.Remove(info.DescAttrNameTagReference); - if (!string.IsNullOrEmpty(info.AckedTagReference)) - _alarmAckedTags.Remove(info.AckedTagReference); - } - - // Delete variable node - if (_tagToVariableNode.TryGetValue(tagRef, out var variable)) - { - try - { - DeleteNode(SystemContext, variable.NodeId); - } - catch - { - /* ignore */ - } - - _tagToVariableNode.Remove(tagRef); - } - - // Clean up remaining mappings - var nodeIdStr = _nodeIdToTagReference.FirstOrDefault(kvp => kvp.Value == tagRef).Key; - if (nodeIdStr != null) - _nodeIdToTagReference.Remove(nodeIdStr); - _tagMetadata.Remove(tagRef); - - VariableNodeCount--; - } - - _gobjectToTagRefs.Remove(id); - } - - // Delete the object/folder node itself - if (_nodeMap.TryGetValue(id, out var objNode)) - { - try - { - DeleteNode(SystemContext, objNode.NodeId); - } - catch - { - /* ignore */ - } - - _nodeMap.Remove(id); - if (!(objNode is FolderState)) - ObjectNodeCount--; - } - } - } - - private void BuildSubtree(List hierarchy, List attributes) - { - if (hierarchy.Count == 0) - return; - - var sorted = TopologicalSort(hierarchy); - var attrsByObject = attributes - .GroupBy(a => a.GobjectId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Find root folder for orphaned nodes - NodeState? rootFolder = null; - if (PredefinedNodes.TryGetValue(new NodeId("ZB", NamespaceIndex), out var rootNode)) - rootFolder = rootNode; - - foreach (var obj in sorted) - { - NodeState parentNode; - if (_nodeMap.TryGetValue(obj.ParentGobjectId, out var p)) - parentNode = p; - else if (rootFolder != null) - parentNode = rootFolder; - else - continue; // no parent available - - // Create node with final NodeId before adding to parent - NodeState node; - var nodeId = new NodeId(obj.TagName, NamespaceIndex); - if (obj.IsArea) - { - var folder = new FolderState(parentNode) - { - SymbolicName = obj.BrowseName, - ReferenceTypeId = ReferenceTypes.Organizes, - TypeDefinitionId = ObjectTypeIds.FolderType, - NodeId = nodeId, - BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex), - DisplayName = new LocalizedText("en", obj.BrowseName), - WriteMask = AttributeWriteMask.None, - UserWriteMask = AttributeWriteMask.None, - EventNotifier = EventNotifiers.None - }; - parentNode.AddChild(folder); - node = folder; - } - else - { - var objNode = new BaseObjectState(parentNode) - { - SymbolicName = obj.BrowseName, - ReferenceTypeId = ReferenceTypes.HasComponent, - TypeDefinitionId = ObjectTypeIds.BaseObjectType, - NodeId = nodeId, - BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex), - DisplayName = new LocalizedText("en", obj.BrowseName), - WriteMask = AttributeWriteMask.None, - UserWriteMask = AttributeWriteMask.None, - EventNotifier = EventNotifiers.None - }; - parentNode.AddChild(objNode); - node = objNode; - ObjectNodeCount++; - } - - AddPredefinedNode(SystemContext, node); - _nodeMap[obj.GobjectId] = node; - - parentNode.ClearChangeMasks(SystemContext, false); - - // Create variable nodes (same logic as BuildAddressSpace) - if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) - { - var byPrimitive = objAttrs - .GroupBy(a => a.PrimitiveName ?? "") - .OrderBy(g => g.Key); - - var primitiveGroupNames = new HashSet( - byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)), - StringComparer.OrdinalIgnoreCase); - - var variableNodes = new Dictionary(StringComparer.OrdinalIgnoreCase); - - var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key)); - if (directGroup != null) - foreach (var attr in directGroup) - { - var variable = CreateAttributeVariable(node, attr); - if (primitiveGroupNames.Contains(attr.AttributeName)) - variableNodes[attr.AttributeName] = variable; - } - - foreach (var group in byPrimitive) - { - if (string.IsNullOrEmpty(group.Key)) - continue; - - NodeState parentForAttrs; - if (variableNodes.TryGetValue(group.Key, out var existingVariable)) - { - parentForAttrs = existingVariable; - } - else - { - var primNode = CreateObject(node, group.Key, group.Key); - primNode.NodeId = new NodeId(obj.TagName + "." + group.Key, NamespaceIndex); - AddPredefinedNode(SystemContext, primNode); - parentForAttrs = primNode; - } - - foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr); - } - } - } - - // Alarm tracking for the new subtree - if (_alarmTrackingEnabled) - { - var includedIds = ResolveAlarmFilterIncludedIds(sorted); - foreach (var obj in sorted) - { - if (obj.IsArea) continue; - if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue; - if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; - - var hasAlarms = false; - var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList(); - foreach (var alarmAttr in alarmAttrs) - { - var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm"; - if (!_tagToVariableNode.ContainsKey(inAlarmTagRef)) - continue; - - var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]") - ? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2) - : alarmAttr.FullTagReference; - - _tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable); - var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex); - - var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex); - var condition = new AlarmConditionState(sourceVariable); - condition.Create(SystemContext, conditionNodeId, - new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex), - new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), - true); - condition.SourceNode.Value = sourceNodeId; - condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); - condition.ConditionName.Value = alarmAttr.AttributeName; - condition.AutoReportStateChanges = true; - condition.SetEnableState(SystemContext, true); - condition.SetActiveState(SystemContext, false); - condition.SetAcknowledgedState(SystemContext, true); - condition.SetSeverity(SystemContext, EventSeverity.Medium); - condition.Retain.Value = false; - condition.OnReportEvent = (context, n, e) => Server.ReportEvent(context, e); - condition.OnAcknowledge = OnAlarmAcknowledge; - - if (sourceVariable != null) - { - sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId); - condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId); - } - - AddPredefinedNode(SystemContext, condition); - - var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); - var alarmInfo = new AlarmInfo - { - SourceTagReference = alarmAttr.FullTagReference, - SourceNodeId = sourceNodeId, - SourceName = alarmAttr.AttributeName, - ConditionNode = condition, - PriorityTagReference = baseTagRef + ".Priority", - DescAttrNameTagReference = baseTagRef + ".DescAttrName", - AckedTagReference = baseTagRef + ".Acked", - AckMsgTagReference = baseTagRef + ".AckMsg" - }; - _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; - _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; - if (!string.IsNullOrEmpty(alarmInfo.PriorityTagReference)) - _alarmPriorityTags[alarmInfo.PriorityTagReference] = alarmInfo; - if (!string.IsNullOrEmpty(alarmInfo.DescAttrNameTagReference)) - _alarmDescTags[alarmInfo.DescAttrNameTagReference] = alarmInfo; - hasAlarms = true; - } - - if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode)) - EnableEventNotifierUpChain(objNode); - } - - // Subscribe alarm tags for new subtree - foreach (var kvp in _alarmInAlarmTags) - { - // Only subscribe tags that belong to the newly built subtree - var gobjectIds = new HashSet(hierarchy.Select(h => h.GobjectId)); - var sourceTagRef = kvp.Value.SourceTagReference; - var ownerAttr = attributes.FirstOrDefault(a => a.FullTagReference == sourceTagRef); - if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId)) - continue; - - foreach (var tag in new[] - { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference }) - { - if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag)) - continue; - TrackBackgroundSubscribe(tag, "subtree alarm auto-subscribe"); - } - } - } - } - - /// - /// Sorts hierarchy so parents always appear before children, regardless of input order. - /// - private static List TopologicalSort(List hierarchy) - { - var byId = hierarchy.ToDictionary(h => h.GobjectId); - var knownIds = new HashSet(hierarchy.Select(h => h.GobjectId)); - var visited = new HashSet(); - var result = new List(hierarchy.Count); - - void Visit(GalaxyObjectInfo obj) - { - if (!visited.Add(obj.GobjectId)) return; - - // Visit parent first if it exists in the hierarchy - if (knownIds.Contains(obj.ParentGobjectId) && byId.TryGetValue(obj.ParentGobjectId, out var parent)) - Visit(parent); - - result.Add(obj); - } - - foreach (var obj in hierarchy) - Visit(obj); - - return result; - } - - private BaseDataVariableState CreateAttributeVariable(NodeState parent, GalaxyAttributeInfo attr) - { - var opcUaDataTypeId = MxDataTypeMapper.MapToOpcUaDataType(attr.MxDataType); - var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId), - attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar); - - var nodeIdString = GetNodeIdentifier(attr); - variable.NodeId = new NodeId(nodeIdString, NamespaceIndex); - - if (attr.IsArray && attr.ArrayDimension.HasValue) - variable.ArrayDimensions = new ReadOnlyList(new List { (uint)attr.ArrayDimension.Value }); - - var accessLevel = SecurityClassificationMapper.IsWritable(attr.SecurityClassification) - ? AccessLevels.CurrentReadOrWrite - : AccessLevels.CurrentRead; - if (attr.IsHistorized) accessLevel |= AccessLevels.HistoryRead; - variable.AccessLevel = accessLevel; - variable.UserAccessLevel = accessLevel; - variable.Historizing = attr.IsHistorized; - - if (attr.IsHistorized) - { - var histConfigNodeId = new NodeId(nodeIdString + ".HAConfiguration", NamespaceIndex); - var histConfig = new BaseObjectState(variable) - { - NodeId = histConfigNodeId, - BrowseName = new QualifiedName("HAConfiguration", NamespaceIndex), - DisplayName = "HA Configuration", - TypeDefinitionId = ObjectTypeIds.HistoricalDataConfigurationType - }; - - var steppedProp = new PropertyState(histConfig) - { - NodeId = new NodeId(nodeIdString + ".HAConfiguration.Stepped", NamespaceIndex), - BrowseName = BrowseNames.Stepped, - DisplayName = "Stepped", - Value = false, - AccessLevel = AccessLevels.CurrentRead, - UserAccessLevel = AccessLevels.CurrentRead - }; - histConfig.AddChild(steppedProp); - - var definitionProp = new PropertyState(histConfig) - { - NodeId = new NodeId(nodeIdString + ".HAConfiguration.Definition", NamespaceIndex), - BrowseName = BrowseNames.Definition, - DisplayName = "Definition", - Value = "Wonderware Historian", - AccessLevel = AccessLevels.CurrentRead, - UserAccessLevel = AccessLevels.CurrentRead - }; - histConfig.AddChild(definitionProp); - - variable.AddChild(histConfig); - AddPredefinedNode(SystemContext, histConfig); - } - - variable.Value = NormalizePublishedValue(attr.FullTagReference, null); - variable.StatusCode = StatusCodes.BadWaitingForInitialData; - variable.Timestamp = DateTime.UtcNow; - - AddPredefinedNode(SystemContext, variable); - _nodeIdToTagReference[nodeIdString] = attr.FullTagReference; - _tagToVariableNode[attr.FullTagReference] = variable; - _tagMetadata[attr.FullTagReference] = new TagMetadata - { - MxDataType = attr.MxDataType, - IsArray = attr.IsArray, - ArrayDimension = attr.ArrayDimension, - SecurityClassification = attr.SecurityClassification - }; - - // Track gobject → tag references for incremental sync - if (!_gobjectToTagRefs.TryGetValue(attr.GobjectId, out var tagList)) - { - tagList = new List(); - _gobjectToTagRefs[attr.GobjectId] = tagList; - } - - tagList.Add(attr.FullTagReference); - - VariableNodeCount++; - return variable; - } - - private static string GetNodeIdentifier(GalaxyAttributeInfo attr) - { - if (!attr.IsArray) - return attr.FullTagReference; - - return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal) - ? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2) - : attr.FullTagReference; - } - - private FolderState CreateFolder(NodeState? parent, string path, string name) - { - var folder = new FolderState(parent) - { - SymbolicName = name, - ReferenceTypeId = ReferenceTypes.Organizes, - TypeDefinitionId = ObjectTypeIds.FolderType, - NodeId = new NodeId(path, NamespaceIndex), - BrowseName = new QualifiedName(name, NamespaceIndex), - DisplayName = new LocalizedText("en", name), - WriteMask = AttributeWriteMask.None, - UserWriteMask = AttributeWriteMask.None, - EventNotifier = EventNotifiers.None - }; - - parent?.AddChild(folder); - return folder; - } - - private BaseObjectState CreateObject(NodeState parent, string path, string name) - { - var obj = new BaseObjectState(parent) - { - SymbolicName = name, - ReferenceTypeId = ReferenceTypes.HasComponent, - TypeDefinitionId = ObjectTypeIds.BaseObjectType, - NodeId = new NodeId(path, NamespaceIndex), - BrowseName = new QualifiedName(name, NamespaceIndex), - DisplayName = new LocalizedText("en", name), - WriteMask = AttributeWriteMask.None, - UserWriteMask = AttributeWriteMask.None, - EventNotifier = EventNotifiers.None - }; - - parent.AddChild(obj); - return obj; - } - - private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, - int valueRank) - { - var variable = new BaseDataVariableState(parent) - { - SymbolicName = name, - ReferenceTypeId = ReferenceTypes.HasComponent, - TypeDefinitionId = VariableTypeIds.BaseDataVariableType, - NodeId = new NodeId(path, NamespaceIndex), - BrowseName = new QualifiedName(name, NamespaceIndex), - DisplayName = new LocalizedText("en", name), - WriteMask = AttributeWriteMask.None, - UserWriteMask = AttributeWriteMask.None, - DataType = dataType, - ValueRank = valueRank, - AccessLevel = AccessLevels.CurrentReadOrWrite, - UserAccessLevel = AccessLevels.CurrentReadOrWrite, - Historizing = false, - StatusCode = StatusCodes.Good, - Timestamp = DateTime.UtcNow - }; - - parent.AddChild(variable); - return variable; - } - - #region Condition Refresh - - /// - /// The OPC UA request context for the condition refresh operation. - /// The monitored event items that should receive retained alarm conditions. - public override ServiceResult ConditionRefresh(OperationContext context, - IList monitoredItems) - { - foreach (var kvp in _alarmInAlarmTags) - { - var info = kvp.Value; - if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true) - continue; - - foreach (var item in monitoredItems) item.QueueEvent(info.ConditionNode); - } - - return ServiceResult.Good; - } - - #endregion - - private sealed class TagMetadata - { - /// - /// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants. - /// - public int MxDataType { get; set; } - - /// - /// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node. - /// - public bool IsArray { get; set; } - - /// - /// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array. - /// - public int? ArrayDimension { get; set; } - - /// - /// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.). - /// Used at write time to determine which write role is required. - /// - public int SecurityClassification { get; set; } - } - - private sealed class AlarmInfo - { - /// - /// Gets or sets the full tag reference for the process value whose alarm state is tracked. - /// - public string SourceTagReference { get; set; } = ""; - - /// - /// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition. - /// - public NodeId SourceNodeId { get; set; } = NodeId.Null; - - /// - /// Gets or sets the operator-facing source name used in generated alarm events. - /// - public string SourceName { get; set; } = ""; - - /// - /// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued. - /// - public bool LastInAlarm { get; set; } - - /// - /// Gets or sets the retained OPC UA condition node associated with the source alarm. - /// - public AlarmConditionState? ConditionNode { get; set; } - - /// - /// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates. - /// - public string PriorityTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text. - /// - public string DescAttrNameTagReference { get; set; } = ""; - - /// - /// Gets or sets the cached OPC UA severity derived from the latest alarm priority value. - /// - public ushort CachedSeverity { get; set; } - - /// - /// Gets or sets the cached alarm message used when emitting active and cleared events. - /// - public string CachedMessage { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference for the alarm acknowledged state. - /// - public string AckedTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment. - /// - public string AckMsgTagReference { get; set; } = ""; - - /// - /// Gets or sets the most recent acknowledged state so duplicate transitions are not reissued. - /// - public bool? LastAcked { get; set; } - } - - #region Read/Write Handlers - - /// - public override void Read(OperationContext context, double maxAge, IList nodesToRead, - IList results, IList errors) - { - base.Read(context, maxAge, nodesToRead, results, errors); - - for (var i = 0; i < nodesToRead.Count; i++) - { - if (nodesToRead[i].AttributeId != Attributes.Value) - continue; - - var nodeId = nodesToRead[i].NodeId; - if (nodeId.NamespaceIndex != NamespaceIndex) continue; - - var nodeIdStr = nodeId.Identifier as string; - if (nodeIdStr == null) continue; - - if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { - // Short-circuit when the owning galaxy runtime host is currently Stopped: - // return the last cached value with BadOutOfService so the operator sees a - // uniform dead-host signal instead of MxAccess silently serving stale data. - // This covers both direct Read requests and OPC UA monitored-item sampling, - // which also flow through this override. - if (IsTagUnderStoppedHost(tagRef)) - { - _tagToVariableNode.TryGetValue(tagRef, out var cachedVar); - results[i] = new DataValue - { - Value = cachedVar?.Value, - StatusCode = StatusCodes.BadOutOfService, - SourceTimestamp = cachedVar?.Timestamp ?? DateTime.UtcNow, - ServerTimestamp = DateTime.UtcNow - }; - errors[i] = ServiceResult.Good; - continue; - } - - try - { - var vtq = SyncOverAsync.WaitSync( - _mxAccessClient.ReadAsync(tagRef), - _mxAccessRequestTimeout, - "MxAccessClient.ReadAsync"); - results[i] = CreatePublishedDataValue(tagRef, vtq); - errors[i] = ServiceResult.Good; - } - catch (TimeoutException ex) - { - Log.Warning(ex, "Read timed out for {TagRef}", tagRef); - errors[i] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - Log.Warning(ex, "Read failed for {TagRef}", tagRef); - errors[i] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - } - - private bool IsTagUnderStoppedHost(string tagRef) - { - if (_galaxyRuntimeProbeManager == null) - return false; - if (!_hostIdsByTagRef.TryGetValue(tagRef, out var hostIds)) - return false; - for (var i = 0; i < hostIds.Count; i++) - if (_galaxyRuntimeProbeManager.IsHostStopped(hostIds[i])) - return true; - return false; - } - - /// - public override void Write(OperationContext context, IList nodesToWrite, - IList errors) - { - base.Write(context, nodesToWrite, errors); - - for (var i = 0; i < nodesToWrite.Count; i++) - { - if (nodesToWrite[i].AttributeId != Attributes.Value) - continue; - - // Skip if base rejected due to access level (read-only node) - if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable) - continue; - - var nodeId = nodesToWrite[i].NodeId; - if (nodeId.NamespaceIndex != NamespaceIndex) continue; - - var nodeIdStr = nodeId.Identifier as string; - if (nodeIdStr == null) continue; - - if (!_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - continue; - - // Check write permission based on the node's security classification - var secClass = _tagMetadata.TryGetValue(tagRef, out var meta) ? meta.SecurityClassification : 1; - if (!HasWritePermission(context, secClass)) - { - errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); - continue; - } - - { - try - { - var writeValue = nodesToWrite[i]; - var value = writeValue.Value.WrappedValue.Value; - - if (!string.IsNullOrWhiteSpace(writeValue.IndexRange)) - { - if (!TryApplyArrayElementWrite(tagRef, value, writeValue.IndexRange, out var updatedArray)) - { - errors[i] = new ServiceResult(StatusCodes.BadIndexRangeInvalid); - continue; - } - - value = updatedArray; - } - - var success = SyncOverAsync.WaitSync( - _mxAccessClient.WriteAsync(tagRef, value), - _mxAccessRequestTimeout, - "MxAccessClient.WriteAsync"); - if (success) - { - PublishLocalWrite(tagRef, value); - errors[i] = ServiceResult.Good; - } - else - { - errors[i] = new ServiceResult(StatusCodes.BadInternalError); - } - } - catch (TimeoutException ex) - { - Log.Warning(ex, "Write timed out for {TagRef}", tagRef); - errors[i] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - Log.Warning(ex, "Write failed for {TagRef}", tagRef); - errors[i] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - } - - private bool HasWritePermission(OperationContext context, int securityClassification) - { - var identity = context.UserIdentity; - - // Check anonymous sessions against AnonymousCanWrite - if (identity?.GrantedRoleIds?.Contains(ObjectIds.WellKnownRole_Anonymous) == true) - return _anonymousCanWrite; - - // When role-based auth is active, require the role matching the security classification - var requiredRoleId = GetRequiredWriteRole(securityClassification); - if (requiredRoleId != null) - return HasGrantedRole(identity, requiredRoleId); - - // No role-based auth — authenticated users can write - return true; - } - - private NodeId? GetRequiredWriteRole(int securityClassification) - { - switch (securityClassification) - { - case 0: // FreeAccess - case 1: // Operate - return _writeOperateRoleId; - case 4: // Tune - return _writeTuneRoleId; - case 5: // Configure - return _writeConfigureRoleId; - default: - // SecuredWrite (2), VerifiedWrite (3), ViewOnly (6) are read-only by AccessLevel - // but if somehow reached, require the most restrictive role - return _writeConfigureRoleId; - } - } - - private bool HasAlarmAckPermission(ISystemContext context) - { - if (_alarmAckRoleId == null) - return true; - - var identity = (context as SystemContext)?.UserIdentity; - return HasGrantedRole(identity, _alarmAckRoleId); - } - - private static bool HasGrantedRole(IUserIdentity? identity, NodeId? roleId) - { - return roleId != null && - identity?.GrantedRoleIds != null && - identity.GrantedRoleIds.Contains(roleId); - } - - private static void EnableEventNotifierUpChain(NodeState node) - { - for (var current = node as BaseInstanceState; - current != null; - current = current.Parent as BaseInstanceState) - if (current is BaseObjectState obj) - obj.EventNotifier = EventNotifiers.SubscribeToEvents; - else if (current is FolderState folder) - folder.EventNotifier = EventNotifiers.SubscribeToEvents; - } - - private void ReportEventUpNotifierChain(BaseInstanceState sourceNode, IFilterTarget eventInstance) - { - for (var current = sourceNode.Parent; current != null; current = (current as BaseInstanceState)?.Parent) - current.ReportEvent(SystemContext, eventInstance); - } - - private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, - out object updatedArray) - { - updatedArray = null!; - - if (!int.TryParse(indexRange, out var index) || index < 0) - return false; - - var currentValue = - NormalizePublishedValue(tagRef, _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value); - if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length) - return false; - - var nextArray = (Array)currentArray.Clone(); - var elementType = currentArray.GetType().GetElementType(); - if (elementType == null) - return false; - - var normalizedValue = NormalizeIndexedWriteValue(writeValue); - nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index); - updatedArray = nextArray; - return true; - } - - private static object? NormalizeIndexedWriteValue(object? value) - { - if (value is Array array && array.Length == 1) - return array.GetValue(0); - return value; - } - - private static object? ConvertArrayElementValue(object? value, Type elementType) - { - if (value == null) - { - if (elementType.IsValueType) - return Activator.CreateInstance(elementType); - return null; - } - - if (elementType.IsInstanceOfType(value)) - return value; - - if (elementType == typeof(string)) - return value.ToString(); - - return Convert.ChangeType(value, elementType); - } - - private void PublishLocalWrite(string tagRef, object? value) - { - if (!_tagToVariableNode.TryGetValue(tagRef, out var variable)) - return; - - var dataValue = CreatePublishedDataValue(tagRef, Vtq.Good(value)); - variable.Value = dataValue.Value; - variable.StatusCode = dataValue.StatusCode; - variable.Timestamp = dataValue.SourceTimestamp; - variable.ClearChangeMasks(SystemContext, false); - } - - private DataValue CreatePublishedDataValue(string tagRef, Vtq vtq) - { - var normalizedValue = NormalizePublishedValue(tagRef, vtq.Value); - if (ReferenceEquals(normalizedValue, vtq.Value)) - return DataValueConverter.FromVtq(vtq); - - return DataValueConverter.FromVtq(new Vtq(normalizedValue, vtq.Timestamp, vtq.Quality)); - } - - private object? NormalizePublishedValue(string tagRef, object? value) - { - if (value != null) - return value; - - if (!_tagMetadata.TryGetValue(tagRef, out var metadata) || !metadata.IsArray || - !metadata.ArrayDimension.HasValue) - return null; - - return CreateDefaultArrayValue(metadata); - } - - private static Array CreateDefaultArrayValue(TagMetadata metadata) - { - var elementType = MxDataTypeMapper.MapToClrType(metadata.MxDataType); - var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value); - - if (elementType == typeof(string)) - for (var i = 0; i < values.Length; i++) - values.SetValue(string.Empty, i); - - return values; - } - - #endregion - - #region HistoryRead - - /// - protected override void HistoryReadRawModified( - ServerSystemContext context, - ReadRawModifiedDetails details, - TimestampsToReturn timestampsToReturn, - IList nodesToRead, - IList results, - IList errors, - List nodesToProcess, - IDictionary cache) - { - foreach (var handle in nodesToProcess) - { - var idx = handle.Index; - - // Handle continuation point resumption - if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0) - { - var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint); - if (remaining == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid); - continue; - } - - ReturnHistoryPage(remaining, details.NumValuesPerNode, results, errors, idx); - continue; - } - - var nodeIdStr = handle.NodeId?.Identifier as string; - if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { - errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown); - continue; - } - - if (_historianDataSource == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); - continue; - } - - if (details.IsReadModified) - { - errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); - continue; - } - - using var historyScope = _metrics.BeginOperation("HistoryReadRaw"); - try - { - var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0; - var dataValues = SyncOverAsync.WaitSync( - _historianDataSource.ReadRawAsync( - tagRef, details.StartTime, details.EndTime, maxValues), - _historianRequestTimeout, - "HistorianDataSource.ReadRawAsync"); - - if (details.ReturnBounds) - AddBoundingValues(dataValues, details.StartTime, details.EndTime); - - ReturnHistoryPage(dataValues, details.NumValuesPerNode, results, errors, idx); - } - catch (TimeoutException ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead raw timed out for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead raw failed for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - - /// - protected override void HistoryReadProcessed( - ServerSystemContext context, - ReadProcessedDetails details, - TimestampsToReturn timestampsToReturn, - IList nodesToRead, - IList results, - IList errors, - List nodesToProcess, - IDictionary cache) - { - foreach (var handle in nodesToProcess) - { - var idx = handle.Index; - - // Handle continuation point resumption - if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0) - { - var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint); - if (remaining == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid); - continue; - } - - ReturnHistoryPage(remaining, 0, results, errors, idx); - continue; - } - - var nodeIdStr = handle.NodeId?.Identifier as string; - if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { - errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown); - continue; - } - - if (_historianDataSource == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); - continue; - } - - if (details.AggregateType == null || details.AggregateType.Count == 0) - { - errors[idx] = new ServiceResult(StatusCodes.BadAggregateListMismatch); - continue; - } - - var aggregateId = details.AggregateType[idx < details.AggregateType.Count ? idx : 0]; - var column = HistorianAggregateMap.MapAggregateToColumn(aggregateId); - if (column == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadAggregateNotSupported); - continue; - } - - using var historyScope = _metrics.BeginOperation("HistoryReadProcessed"); - try - { - var dataValues = SyncOverAsync.WaitSync( - _historianDataSource.ReadAggregateAsync( - tagRef, details.StartTime, details.EndTime, - details.ProcessingInterval, column), - _historianRequestTimeout, - "HistorianDataSource.ReadAggregateAsync"); - - ReturnHistoryPage(dataValues, 0, results, errors, idx); - } - catch (TimeoutException ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead processed timed out for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - - /// - protected override void HistoryReadAtTime( - ServerSystemContext context, - ReadAtTimeDetails details, - TimestampsToReturn timestampsToReturn, - IList nodesToRead, - IList results, - IList errors, - List nodesToProcess, - IDictionary cache) - { - foreach (var handle in nodesToProcess) - { - var idx = handle.Index; - var nodeIdStr = handle.NodeId?.Identifier as string; - if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { - errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown); - continue; - } - - if (_historianDataSource == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); - continue; - } - - if (details.ReqTimes == null || details.ReqTimes.Count == 0) - { - errors[idx] = new ServiceResult(StatusCodes.BadInvalidArgument); - continue; - } - - using var historyScope = _metrics.BeginOperation("HistoryReadAtTime"); - try - { - var timestamps = new DateTime[details.ReqTimes.Count]; - for (var i = 0; i < details.ReqTimes.Count; i++) - timestamps[i] = details.ReqTimes[i]; - - var dataValues = SyncOverAsync.WaitSync( - _historianDataSource.ReadAtTimeAsync(tagRef, timestamps), - _historianRequestTimeout, - "HistorianDataSource.ReadAtTimeAsync"); - - var historyData = new HistoryData(); - historyData.DataValues.AddRange(dataValues); - - results[idx] = new HistoryReadResult - { - StatusCode = StatusCodes.Good, - HistoryData = new ExtensionObject(historyData) - }; - errors[idx] = ServiceResult.Good; - } - catch (TimeoutException ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead at-time timed out for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead at-time failed for {TagRef}", tagRef); - errors[idx] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - - /// - protected override void HistoryReadEvents( - ServerSystemContext context, - ReadEventDetails details, - TimestampsToReturn timestampsToReturn, - IList nodesToRead, - IList results, - IList errors, - List nodesToProcess, - IDictionary cache) - { - foreach (var handle in nodesToProcess) - { - var idx = handle.Index; - var nodeIdStr = handle.NodeId?.Identifier as string; - - if (_historianDataSource == null) - { - errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); - continue; - } - - // Resolve the source name for event filtering. - // Alarm condition nodes end with ".Condition" — strip to get the source tag. - // Area/object nodes filter by Source_Name matching the browse name. - string? sourceName = null; - if (nodeIdStr != null) - { - if (nodeIdStr.EndsWith(".Condition")) - { - var baseTag = nodeIdStr.Substring(0, nodeIdStr.Length - ".Condition".Length); - sourceName = baseTag; - } - else if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { - sourceName = tagRef; - } - } - - using var historyScope = _metrics.BeginOperation("HistoryReadEvents"); - try - { - var maxEvents = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0; - var events = SyncOverAsync.WaitSync( - _historianDataSource.ReadEventsAsync( - sourceName, details.StartTime, details.EndTime, maxEvents), - _historianRequestTimeout, - "HistorianDataSource.ReadEventsAsync"); - - var historyEvent = new HistoryEvent(); - foreach (var evt in events) - { - // Build the standard event field list per OPC UA Part 11 - // Fields: EventId, EventType, SourceNode, SourceName, Time, ReceiveTime, - // Message, Severity - var fields = new HistoryEventFieldList(); - fields.EventFields.Add(new Variant(evt.Id.ToByteArray())); - fields.EventFields.Add(new Variant(ObjectTypeIds.AlarmConditionType)); - fields.EventFields.Add(new Variant( - nodeIdStr != null ? new NodeId(nodeIdStr, NamespaceIndex) : NodeId.Null)); - fields.EventFields.Add(new Variant(evt.Source ?? "")); - fields.EventFields.Add(new Variant( - DateTime.SpecifyKind(evt.EventTime, DateTimeKind.Utc))); - fields.EventFields.Add(new Variant( - DateTime.SpecifyKind(evt.ReceivedTime, DateTimeKind.Utc))); - fields.EventFields.Add(new Variant(new LocalizedText(evt.DisplayText ?? ""))); - fields.EventFields.Add(new Variant((ushort)evt.Severity)); - historyEvent.Events.Add(fields); - } - - results[idx] = new HistoryReadResult - { - StatusCode = StatusCodes.Good, - HistoryData = new ExtensionObject(historyEvent) - }; - errors[idx] = ServiceResult.Good; - } - catch (TimeoutException ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead events timed out for {NodeId}", nodeIdStr); - errors[idx] = new ServiceResult(StatusCodes.BadTimeout); - } - catch (Exception ex) - { - historyScope.SetSuccess(false); - Log.Warning(ex, "HistoryRead events failed for {NodeId}", nodeIdStr); - errors[idx] = new ServiceResult(StatusCodes.BadInternalError); - } - } - } - - private void ReturnHistoryPage(List dataValues, uint numValuesPerNode, - IList results, IList errors, int idx) - { - var pageSize = numValuesPerNode > 0 ? (int)numValuesPerNode : dataValues.Count; - - var historyData = new HistoryData(); - byte[]? continuationPoint = null; - - if (dataValues.Count > pageSize) - { - historyData.DataValues.AddRange(dataValues.GetRange(0, pageSize)); - var remainder = dataValues.GetRange(pageSize, dataValues.Count - pageSize); - continuationPoint = _historyContinuations.Store(remainder); - } - else - { - historyData.DataValues.AddRange(dataValues); - } - - results[idx] = new HistoryReadResult - { - StatusCode = StatusCodes.Good, - HistoryData = new ExtensionObject(historyData), - ContinuationPoint = continuationPoint - }; - errors[idx] = ServiceResult.Good; - } - - private static void AddBoundingValues(List dataValues, DateTime startTime, DateTime endTime) - { - // Insert start bound if first sample doesn't match start time - if (dataValues.Count == 0 || dataValues[0].SourceTimestamp != startTime) - { - dataValues.Insert(0, new DataValue - { - Value = Variant.Null, - SourceTimestamp = startTime, - ServerTimestamp = startTime, - StatusCode = StatusCodes.BadBoundNotFound - }); - } - - // Append end bound if last sample doesn't match end time - if (dataValues.Count == 0 || dataValues[dataValues.Count - 1].SourceTimestamp != endTime) - { - dataValues.Add(new DataValue - { - Value = Variant.Null, - SourceTimestamp = endTime, - ServerTimestamp = endTime, - StatusCode = StatusCodes.BadBoundNotFound - }); - } - } - - #endregion - - #region Subscription Delivery - - /// - /// Called by the OPC UA framework during monitored item creation. - /// Triggers ref-counted MXAccess subscriptions early so the runtime value - /// can arrive before the initial publish to the client. - /// - /// - protected override void OnMonitoredItemCreated(ServerSystemContext context, NodeHandle handle, - MonitoredItem monitoredItem) - { - base.OnMonitoredItemCreated(context, handle, monitoredItem); - - var nodeIdStr = handle?.NodeId?.Identifier as string; - if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - SubscribeTag(tagRef); - } - - /// - /// Called by the OPC UA framework after monitored items are deleted. - /// Decrements ref-counted MXAccess subscriptions. - /// - /// - protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, - IList monitoredItems) - { - foreach (var item in monitoredItems) - { - var nodeIdStr = GetNodeIdString(item); - if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - UnsubscribeTag(tagRef); - } - } - - /// - /// Called by the OPC UA framework after monitored items are transferred to a new session. - /// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state. - /// - /// - protected override void OnMonitoredItemsTransferred(ServerSystemContext context, - IList monitoredItems) - { - base.OnMonitoredItemsTransferred(context, monitoredItems); - - var transferredTagRefs = monitoredItems - .Select(GetNodeIdString) - .Where(nodeIdStr => nodeIdStr != null && _nodeIdToTagReference.ContainsKey(nodeIdStr)) - .Select(nodeIdStr => _nodeIdToTagReference[nodeIdStr!]) - .ToList(); - - RestoreTransferredSubscriptions(transferredTagRefs); - } - - /// - protected override void OnModifyMonitoredItemsComplete(ServerSystemContext context, - IList monitoredItems) - { - foreach (var item in monitoredItems) - Log.Debug("MonitoredItem modified: Id={Id}, SamplingInterval={Interval}ms", - item.Id, item.SamplingInterval); - } - - private static string? GetNodeIdString(IMonitoredItem item) - { - if (item.ManagerHandle is NodeState node) - return node.NodeId?.Identifier as string; - return null; - } - - /// - /// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC - /// UA monitored item appears. - /// - /// The fully qualified Galaxy tag reference to subscribe. - internal void SubscribeTag(string fullTagReference) - { - var shouldSubscribe = false; - lock (Lock) - { - if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count)) - { - _subscriptionRefCounts[fullTagReference] = count + 1; - } - else - { - _subscriptionRefCounts[fullTagReference] = 1; - shouldSubscribe = true; - } - } - - if (shouldSubscribe) - { - try - { - _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { }).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to subscribe tag {Tag}", fullTagReference); - } - } - } - - /// - /// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA - /// monitored items remain. - /// - /// The fully qualified Galaxy tag reference to unsubscribe. - internal void UnsubscribeTag(string fullTagReference) - { - var shouldUnsubscribe = false; - lock (Lock) - { - if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count)) - { - if (count <= 1) - { - _subscriptionRefCounts.Remove(fullTagReference); - shouldUnsubscribe = true; - } - else - { - _subscriptionRefCounts[fullTagReference] = count - 1; - } - } - } - - if (shouldUnsubscribe) - { - try - { - _mxAccessClient.UnsubscribeAsync(fullTagReference).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to unsubscribe tag {Tag}", fullTagReference); - } - } - } - - /// - /// Rebuilds subscription reference counts for monitored items that were transferred by the OPC UA stack. - /// Existing in-memory bookkeeping is preserved to avoid double-counting normal in-process transfers. - /// - /// The Galaxy tag references represented by the transferred monitored items. - internal void RestoreTransferredSubscriptions(IEnumerable fullTagReferences) - { - var transferredCounts = fullTagReferences - .GroupBy(tagRef => tagRef, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); - - var tagsToSubscribe = new List(); - foreach (var kvp in transferredCounts) - lock (Lock) - { - if (_subscriptionRefCounts.ContainsKey(kvp.Key)) - continue; - - _subscriptionRefCounts[kvp.Key] = kvp.Value; - tagsToSubscribe.Add(kvp.Key); - } - - foreach (var tagRef in tagsToSubscribe) - TrackBackgroundSubscribe(tagRef, "transferred subscription restore"); - } - - private void OnMxAccessDataChange(string address, Vtq vtq) - { - if (_dispatchDisposed) - return; - - // Runtime status probes are bridge-owned subscriptions whose only job is to drive the - // host state machine; they are NOT in _tagToVariableNode, so the normal dispatch path - // would drop them anyway. Route probe addresses directly to the probe manager and skip - // the dispatch queue entirely. - if (_galaxyRuntimeProbeManager != null - && _galaxyRuntimeProbeManager.HandleProbeUpdate(address, vtq)) - return; - - Interlocked.Increment(ref _totalMxChangeEvents); - _pendingDataChanges[address] = vtq; - try - { - _dataChangeSignal.Set(); - } - catch (ObjectDisposedException) - { - // Shutdown may race with one final callback from the runtime. - } - } - - #endregion - - #region Data Change Dispatch - - private void StartDispatchThread() - { - _dispatchRunning = true; - _dispatchThread = new Thread(DispatchLoop) - { - Name = "OpcUaDataChangeDispatch", - IsBackground = true - }; - _dispatchThread.Start(); - } - - private void StopDispatchThread() - { - _dispatchRunning = false; - _dataChangeSignal.Set(); - _dispatchThread?.Join(TimeSpan.FromSeconds(5)); - } - - private void DispatchLoop() - { - Log.Information("Data change dispatch thread started"); - - while (_dispatchRunning) - try - { - _dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100)); - - if (!_dispatchRunning) - break; - - // Drive time-based probe state transitions on every dispatch tick. The dispatch - // loop already wakes every 100ms via the WaitOne timeout, so this gives us a - // ~10Hz cadence for the Unknown → Stopped timeout without introducing a new - // thread or timer. No-op when the probe manager is disabled. - _galaxyRuntimeProbeManager?.Tick(); - - // Drain any host-state transitions queued from the STA probe callback. Each - // Mark/Clear call takes its own node manager Lock, which is safe here because - // the dispatch thread is not currently holding it. - while (_pendingHostStateChanges.TryDequeue(out var transition)) - { - if (transition.Stopped) - MarkHostVariablesBadQuality(transition.GobjectId); - else - ClearHostVariablesBadQuality(transition.GobjectId); - - // Also refresh the synthetic $RuntimeState child nodes on this host so - // subscribed OPC UA clients see the state change in the same publish cycle. - UpdateHostRuntimeStatusNodes(transition.GobjectId); - } - - var keys = _pendingDataChanges.Keys.ToList(); - if (keys.Count == 0) - { - ReportDispatchMetricsIfDue(); - continue; - } - - // Prepare updates outside the Lock. Shared-state lookups stay inside the Lock. - var updates = - new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count); - var pendingAlarmEvents = - new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>(); - var pendingAckedEvents = new List<(AlarmInfo info, bool acked)>(); - - foreach (var address in keys) - { - if (!_pendingDataChanges.TryRemove(address, out var vtq)) - continue; - - // Suppress updates for tags whose owning Galaxy runtime host is currently - // Stopped. Without this, MxAccess keeps streaming cached values that would - // overwrite the BadOutOfService set by MarkHostVariablesBadQuality — the - // variables would flicker Bad→Good every dispatch cycle and subscribers - // would see a flood of notifications (the original "client freeze" symptom). - // Dropping at the source also means we do no lock/alarm work for dead data. - if (IsTagUnderStoppedHost(address)) - { - Interlocked.Increment(ref _suppressedUpdatesCount); - continue; - } - - AlarmInfo? alarmInfo = null; - AlarmInfo? ackedAlarmInfo = null; - var newInAlarm = false; - var newAcked = false; - - lock (Lock) - { - if (_tagToVariableNode.TryGetValue(address, out var variable)) - try - { - var dataValue = CreatePublishedDataValue(address, vtq); - updates.Add((address, variable, dataValue)); - } - catch (Exception ex) - { - Log.Warning(ex, "Error preparing data change for {Address}", address); - } - - if (_alarmInAlarmTags.TryGetValue(address, out alarmInfo)) - { - newInAlarm = vtq.Value is true || vtq.Value is 1 || - (vtq.Value is int intVal && intVal != 0); - if (newInAlarm == alarmInfo.LastInAlarm) - alarmInfo = null; - } - - // Cache alarm priority/description values as they arrive via subscription - if (_alarmPriorityTags.TryGetValue(address, out var priorityInfo)) - { - if (vtq.Value is int ipCache) - priorityInfo.CachedSeverity = - (ushort)Math.Min(Math.Max(ipCache, 1), 1000); - else if (vtq.Value is short spCache) - priorityInfo.CachedSeverity = - (ushort)Math.Min(Math.Max((int)spCache, 1), 1000); - } - - if (_alarmDescTags.TryGetValue(address, out var descInfo)) - { - if (vtq.Value is string descCache && !string.IsNullOrEmpty(descCache)) - descInfo.CachedMessage = descCache; - } - - // Check for Acked transitions — skip if state hasn't changed - if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo)) - { - newAcked = vtq.Value is true || vtq.Value is 1 || - (vtq.Value is int ackedIntVal && ackedIntVal != 0); - if (ackedAlarmInfo.LastAcked.HasValue && newAcked == ackedAlarmInfo.LastAcked.Value) - ackedAlarmInfo = null; // No transition → skip - else - { - pendingAckedEvents.Add((ackedAlarmInfo, newAcked)); - Interlocked.Increment(ref _alarmAckEventCount); - } - } - } - - if (alarmInfo == null) - continue; - - ushort? severity = null; - string? message = null; - - if (newInAlarm) - { - // Use cached values from subscription data changes instead of blocking reads - severity = alarmInfo.CachedSeverity > 0 ? alarmInfo.CachedSeverity : (ushort?)null; - message = !string.IsNullOrEmpty(alarmInfo.CachedMessage) - ? alarmInfo.CachedMessage - : null; - } - - pendingAlarmEvents.Add((address, alarmInfo, newInAlarm, severity, message)); - Interlocked.Increment(ref _alarmTransitionCount); - } - - // Apply under Lock so ClearChangeMasks propagates to monitored items. - if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0) - lock (Lock) - { - foreach (var (address, variable, dataValue) in updates) - { - if (!_tagToVariableNode.TryGetValue(address, out var currentVariable) || - !ReferenceEquals(currentVariable, variable)) - continue; - - variable.Value = dataValue.Value; - variable.StatusCode = dataValue.StatusCode; - variable.Timestamp = dataValue.SourceTimestamp; - variable.ClearChangeMasks(SystemContext, false); - } - - foreach (var (address, info, active, severity, message) in pendingAlarmEvents) - { - if (!_alarmInAlarmTags.TryGetValue(address, out var currentInfo) || - !ReferenceEquals(currentInfo, info)) - continue; - - if (currentInfo.LastInAlarm == active) - continue; - - currentInfo.LastInAlarm = active; - if (severity.HasValue) - currentInfo.CachedSeverity = severity.Value; - if (!string.IsNullOrEmpty(message)) - currentInfo.CachedMessage = message!; - - try - { - ReportAlarmEvent(currentInfo, active); - } - catch (Exception ex) - { - Log.Warning(ex, "Error reporting alarm event for {Source}", currentInfo.SourceName); - } - } - - // Apply Acked state changes - foreach (var (info, acked) in pendingAckedEvents) - { - // Double-check dedup under lock - if (info.LastAcked.HasValue && acked == info.LastAcked.Value) - continue; - - info.LastAcked = acked; - - var condition = info.ConditionNode; - if (condition == null) continue; - - try - { - condition.SetAcknowledgedState(SystemContext, acked); - condition.Retain.Value = condition.ActiveState?.Id?.Value == true || !acked; - - if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src)) - ReportEventUpNotifierChain(src, condition); - - Log.Information("Alarm {AckState}: {Source}", - acked ? "ACKNOWLEDGED" : "UNACKNOWLEDGED", info.SourceName); - } - catch (Exception ex) - { - Log.Warning(ex, "Error updating acked state for {Source}", info.SourceName); - } - } - } - - Interlocked.Add(ref _totalDispatchBatchSize, updates.Count); - Interlocked.Increment(ref _dispatchCycleCount); - ReportDispatchMetricsIfDue(); - } - catch (Exception ex) - { - Log.Error(ex, "Unhandled error in data change dispatch loop"); - } - - Log.Information("Data change dispatch thread stopped"); - } - - private void ReportDispatchMetricsIfDue() - { - var now = DateTime.UtcNow; - var elapsed = (now - _lastMetricsReportTime).TotalSeconds; - if (elapsed < 60) return; - - var totalEvents = Interlocked.Read(ref _totalMxChangeEvents); - var lastReported = Interlocked.Read(ref _lastReportedMxChangeEvents); - var eventsPerSecond = (totalEvents - lastReported) / elapsed; - Interlocked.Exchange(ref _lastReportedMxChangeEvents, totalEvents); - - var batchSize = Interlocked.Read(ref _totalDispatchBatchSize); - var cycles = Interlocked.Read(ref _dispatchCycleCount); - var avgQueueSize = cycles > 0 ? (double)batchSize / cycles : 0; - var suppressed = Interlocked.Exchange(ref _suppressedUpdatesCount, 0); - - // Reset rolling counters - Interlocked.Exchange(ref _totalDispatchBatchSize, 0); - Interlocked.Exchange(ref _dispatchCycleCount, 0); - - _lastMetricsReportTime = now; - MxChangeEventsPerSecond = eventsPerSecond; - AverageDispatchBatchSize = avgQueueSize; - - Log.Information( - "DataChange dispatch: EventsPerSec={EventsPerSec:F1}, AvgBatchSize={AvgBatchSize:F1}, PendingItems={Pending}, TotalEvents={Total}, SuppressedStopped={Suppressed}", - eventsPerSecond, avgQueueSize, _pendingDataChanges.Count, totalEvents, suppressed); - } - - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - _dispatchDisposed = true; - _mxAccessClient.OnTagValueChanged -= OnMxAccessDataChange; - // Dispose the runtime probe manager before the MxAccess client teardown so its - // Unadvise calls reach a live client. Disposing the node manager normally runs - // BEFORE the node manager's containing OpcUaServerHost releases the MxAccess - // client, so the probes close cleanly. - _galaxyRuntimeProbeManager?.Dispose(); - StopDispatchThread(); - DrainPendingBackgroundSubscribes(); - _dataChangeSignal.Dispose(); - } - - base.Dispose(disposing); - } - - private void DrainPendingBackgroundSubscribes() - { - var snapshot = _pendingBackgroundSubscribes.Values.ToArray(); - if (snapshot.Length == 0) - return; - - try - { - Task.WaitAll(snapshot, TimeSpan.FromSeconds(5)); - Log.Information("Drained {Count} pending background subscribe(s) on shutdown", snapshot.Length); - } - catch (AggregateException ex) - { - // Individual faults were already logged by the tracked continuation; record the - // aggregate at debug level to aid diagnosis without double-logging each failure. - Log.Debug(ex, "Background subscribe drain completed with {FaultCount} fault(s)", - ex.InnerExceptions.Count); - } - } - - #endregion - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs deleted file mode 100644 index a62f66c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ /dev/null @@ -1,528 +0,0 @@ -using System; -using System.Collections.Generic; -using Opc.Ua; -using Opc.Ua.Server; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Historian; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Custom OPC UA server that creates the LmxNodeManager, handles user authentication, - /// and exposes redundancy state through the standard server object. (OPC-001, OPC-012) - /// - public class LmxOpcUaServer : StandardServer - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly bool _alarmTrackingEnabled; - private readonly AlarmObjectFilter? _alarmObjectFilter; - private readonly string? _applicationUri; - private readonly AuthenticationConfiguration _authConfig; - private readonly IUserAuthenticationProvider? _authProvider; - - private readonly string _galaxyName; - private readonly IHistorianDataSource? _historianDataSource; - private readonly PerformanceMetrics _metrics; - private readonly IMxAccessClient _mxAccessClient; - private readonly RedundancyConfiguration _redundancyConfig; - private readonly ServiceLevelCalculator _serviceLevelCalculator = new(); - private NodeId? _alarmAckRoleId; - - // Resolved custom role NodeIds (populated in CreateMasterNodeManager) - private NodeId? _readOnlyRoleId; - private NodeId? _writeConfigureRoleId; - private NodeId? _writeOperateRoleId; - private NodeId? _writeTuneRoleId; - - private readonly bool _runtimeStatusProbesEnabled; - private readonly int _runtimeStatusUnknownTimeoutSeconds; - private readonly int _mxAccessRequestTimeoutSeconds; - private readonly int _historianRequestTimeoutSeconds; - - public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, - IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, - AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null, - RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null, - AlarmObjectFilter? alarmObjectFilter = null, - bool runtimeStatusProbesEnabled = false, - int runtimeStatusUnknownTimeoutSeconds = 15, - int mxAccessRequestTimeoutSeconds = 30, - int historianRequestTimeoutSeconds = 60) - { - _galaxyName = galaxyName; - _mxAccessClient = mxAccessClient; - _metrics = metrics; - _historianDataSource = historianDataSource; - _alarmTrackingEnabled = alarmTrackingEnabled; - _alarmObjectFilter = alarmObjectFilter; - _authConfig = authConfig ?? new AuthenticationConfiguration(); - _authProvider = authProvider; - _redundancyConfig = redundancyConfig ?? new RedundancyConfiguration(); - _applicationUri = applicationUri; - _runtimeStatusProbesEnabled = runtimeStatusProbesEnabled; - _runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds; - _mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds; - _historianRequestTimeoutSeconds = historianRequestTimeoutSeconds; - } - - /// - /// Gets the custom node manager that publishes the Galaxy-backed namespace. - /// - public LmxNodeManager? NodeManager { get; private set; } - - /// - /// Gets the number of active OPC UA sessions currently connected to the server. - /// - public int ActiveSessionCount - { - get - { - try - { - return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; - } - catch - { - return 0; - } - } - } - - /// - protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, - ApplicationConfiguration configuration) - { - // Resolve custom role NodeIds from the roles namespace - ResolveRoleNodeIds(server); - - var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; - NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, - _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, - _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId, - _alarmObjectFilter, - _runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds, - _mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds); - - var nodeManagers = new List { NodeManager }; - return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); - } - - private void ResolveRoleNodeIds(IServerInternal server) - { - var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri); - _readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex); - _writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex); - _writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex); - _writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex); - _alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex); - Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex); - } - - /// - protected override void OnServerStarted(IServerInternal server) - { - base.OnServerStarted(server); - server.SessionManager.ImpersonateUser += OnImpersonateUser; - - ConfigureRedundancy(server); - ConfigureHistoryCapabilities(server); - ConfigureServerCapabilities(server); - } - - private void ConfigureRedundancy(IServerInternal server) - { - var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled); - - try - { - // Set RedundancySupport via the diagnostics node manager - var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport; - var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode( - redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState; - - if (redundancySupportNode != null) - { - redundancySupportNode.Value = (int)mode; - redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false); - Log.Information("Set RedundancySupport to {Mode}", mode); - } - - // Set ServerUriArray for non-transparent redundancy - if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0) - { - var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray; - var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode( - serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState; - - if (serverUriArrayNode != null) - { - serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray(); - serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false); - Log.Information("Set ServerUriArray to [{Uris}]", - string.Join(", ", _redundancyConfig.ServerUris)); - } - else - { - Log.Warning( - "ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type"); - } - } - - // Set initial ServiceLevel - var initialLevel = CalculateCurrentServiceLevel(true, true); - SetServiceLevelValue(server, initialLevel); - Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel); - } - catch (Exception ex) - { - Log.Warning(ex, - "Failed to configure redundancy nodes — redundancy state may not be visible to clients"); - } - } - - private void ConfigureHistoryCapabilities(IServerInternal server) - { - if (_historianDataSource == null) - return; - - try - { - var dnm = server.DiagnosticsNodeManager; - var ctx = server.DefaultSystemContext; - - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability, - _alarmTrackingEnabled); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_MaxReturnDataValues, - (uint)(_historianDataSource != null ? 10000 : 0)); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_InsertDataCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_UpdateDataCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_DeleteRawCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_InsertEventCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_UpdateEventCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_DeleteEventCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false); - SetPredefinedVariable(dnm, ctx, - VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true); - - // Add aggregate function references under the AggregateFunctions folder - var aggFolderNode = dnm?.FindPredefinedNode( - ObjectIds.HistoryServerCapabilities_AggregateFunctions, - typeof(FolderState)) as FolderState; - - if (aggFolderNode != null) - { - var aggregateIds = new[] - { - ObjectIds.AggregateFunction_Average, - ObjectIds.AggregateFunction_Minimum, - ObjectIds.AggregateFunction_Maximum, - ObjectIds.AggregateFunction_Count, - ObjectIds.AggregateFunction_Start, - ObjectIds.AggregateFunction_End, - ObjectIds.AggregateFunction_StandardDeviationPopulation - }; - - foreach (var aggId in aggregateIds) - { - var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState; - if (aggNode != null) - { - try - { - aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId); - } - catch (ArgumentException) - { - // Reference already exists — skip - } - - try - { - aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId); - } - catch (ArgumentException) - { - // Reference already exists — skip - } - } - } - - Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions", - aggregateIds.Length); - } - else - { - Log.Warning("AggregateFunctions folder not found in predefined nodes"); - } - } - catch (Exception ex) - { - Log.Warning(ex, - "Failed to configure HistoryServerCapabilities — history discovery may not work for clients"); - } - } - - private void ConfigureServerCapabilities(IServerInternal server) - { - try - { - var dnm = server.DiagnosticsNodeManager; - var ctx = server.DefaultSystemContext; - - // Server profiles - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_ServerProfileArray, - new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" }); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_LocaleIdArray, - new[] { "en" }); - - // Limits - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024)); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024)); - - // OperationLimits - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds, - (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0); - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0); - - // Diagnostics - SetPredefinedVariable(dnm, ctx, - VariableIds.Server_ServerDiagnostics_EnabledFlag, true); - - Log.Information( - "ServerCapabilities configured (OperationLimits, diagnostics enabled)"); - } - catch (Exception ex) - { - Log.Warning(ex, - "Failed to configure ServerCapabilities — capability discovery may not work for clients"); - } - } - - private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx, - NodeId variableId, object value) - { - var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState; - if (node != null) - { - node.Value = value; - node.ClearChangeMasks(ctx, false); - } - } - - /// - /// Updates the server's ServiceLevel based on current runtime health. - /// Called by the service layer when MXAccess or DB health changes. - /// - public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) - { - var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected); - try - { - if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level); - } - catch (Exception ex) - { - Log.Debug(ex, "Failed to update ServiceLevel node"); - } - } - - private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected) - { - if (!_redundancyConfig.Enabled) - return 255; // SDK default when redundancy is not configured - - var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); - var baseLevel = isPrimary - ? _redundancyConfig.ServiceLevelBase - : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); - - return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected); - } - - private static void SetServiceLevelValue(IServerInternal server, byte level) - { - var serviceLevelNodeId = VariableIds.Server_ServiceLevel; - var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode( - serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState; - - if (serviceLevelNode != null) - { - serviceLevelNode.Value = level; - serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false); - } - } - - private void OnImpersonateUser(Session session, ImpersonateEventArgs args) - { - if (args.NewIdentity is AnonymousIdentityToken anonymousToken) - { - if (!_authConfig.AllowAnonymous) - throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, - "Anonymous access is disabled"); - - args.Identity = new RoleBasedIdentity( - new UserIdentity(anonymousToken), - new List { Role.Anonymous }); - Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite); - return; - } - - if (args.NewIdentity is UserNameIdentityToken userNameToken) - { - var password = userNameToken.DecryptedPassword ?? ""; - - if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password)) - { - Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}", - userNameToken.UserName, session?.Id); - throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password"); - } - - var roles = new List { Role.AuthenticatedUser }; - - if (_authProvider is IRoleProvider roleProvider) - { - var appRoles = roleProvider.GetUserRoles(userNameToken.UserName); - - foreach (var appRole in appRoles) - switch (appRole) - { - case AppRoles.ReadOnly: - if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly)); - break; - case AppRoles.WriteOperate: - if (_writeOperateRoleId != null) - roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate)); - break; - case AppRoles.WriteTune: - if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune)); - break; - case AppRoles.WriteConfigure: - if (_writeConfigureRoleId != null) - roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure)); - break; - case AppRoles.AlarmAck: - if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck)); - break; - } - - Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}", - userNameToken.UserName, string.Join(", ", appRoles), session?.Id); - } - else - { - Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}", - userNameToken.UserName, session?.Id); - } - - args.Identity = new RoleBasedIdentity( - new UserIdentity(userNameToken), roles); - return; - } - - if (args.NewIdentity is X509IdentityToken x509Token) - { - var cert = x509Token.Certificate; - var subject = cert?.Subject ?? "Unknown"; - - // Extract CN from certificate subject for display - var cn = subject; - var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase); - if (cnStart >= 0) - { - cn = subject.Substring(cnStart + 3); - var commaIdx = cn.IndexOf(','); - if (commaIdx >= 0) - cn = cn.Substring(0, commaIdx); - } - - var roles = new List { Role.AuthenticatedUser }; - - // X.509 authenticated users get ReadOnly role by default - if (_readOnlyRoleId != null) - roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly)); - - args.Identity = new RoleBasedIdentity( - new UserIdentity(x509Token), roles); - Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}", - cn, subject, cert?.Thumbprint); - return; - } - - throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type"); - } - - /// - protected override ServerProperties LoadServerProperties() - { - var properties = new ServerProperties - { - ManufacturerName = "ZB MOM", - ProductName = "LmxOpcUa Server", - ProductUri = $"urn:{_galaxyName}:LmxOpcUa", - SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0", - BuildNumber = "1", - BuildDate = DateTime.UtcNow - }; - return properties; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs deleted file mode 100644 index d2fea2e..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaQualityMapper.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Opc.Ua; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005) - /// - public static class OpcUaQualityMapper - { - /// - /// Converts bridge quality values into OPC UA status codes. - /// - /// The bridge quality value. - /// The OPC UA status code to publish. - public static StatusCode ToStatusCode(Quality quality) - { - return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality)); - } - - /// - /// Converts an OPC UA status code back into a bridge quality category. - /// - /// The OPC UA status code to interpret. - /// The bridge quality category represented by the status code. - public static Quality FromStatusCode(StatusCode statusCode) - { - if (StatusCode.IsGood(statusCode)) return Quality.Good; - if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain; - return Quality.Bad; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs deleted file mode 100644 index a01da56..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Opc.Ua; -using Opc.Ua.Configuration; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Historian; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013) - /// - public class OpcUaServerHost : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly AlarmObjectFilter? _alarmObjectFilter; - private readonly AuthenticationConfiguration _authConfig; - private readonly IUserAuthenticationProvider? _authProvider; - - private readonly OpcUaConfiguration _config; - private readonly IHistorianDataSource? _historianDataSource; - private readonly PerformanceMetrics _metrics; - private readonly IMxAccessClient _mxAccessClient; - private readonly RedundancyConfiguration _redundancyConfig; - private readonly SecurityProfileConfiguration _securityConfig; - private ApplicationInstance? _application; - private LmxOpcUaServer? _server; - - /// - /// Initializes a new host for the Galaxy-backed OPC UA server instance. - /// - /// The endpoint and session settings for the OPC UA host. - /// The runtime client used by the node manager for live reads, writes, and subscriptions. - /// The metrics collector shared with the node manager and runtime bridge. - /// The optional historian adapter that enables OPC UA history read support. - public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, - IHistorianDataSource? historianDataSource = null, - AuthenticationConfiguration? authConfig = null, - IUserAuthenticationProvider? authProvider = null, - SecurityProfileConfiguration? securityConfig = null, - RedundancyConfiguration? redundancyConfig = null, - AlarmObjectFilter? alarmObjectFilter = null, - MxAccessConfiguration? mxAccessConfig = null, - HistorianConfiguration? historianConfig = null) - { - _config = config; - _mxAccessClient = mxAccessClient; - _metrics = metrics; - _historianDataSource = historianDataSource; - _authConfig = authConfig ?? new AuthenticationConfiguration(); - _authProvider = authProvider; - _securityConfig = securityConfig ?? new SecurityProfileConfiguration(); - _redundancyConfig = redundancyConfig ?? new RedundancyConfiguration(); - _alarmObjectFilter = alarmObjectFilter; - _mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration(); - _historianConfig = historianConfig ?? new HistorianConfiguration(); - } - - private readonly MxAccessConfiguration _mxAccessConfig; - private readonly HistorianConfiguration _historianConfig; - - /// - /// Gets the active node manager that holds the published Galaxy namespace. - /// - public LmxNodeManager? NodeManager => _server?.NodeManager; - - /// - /// Gets the number of currently connected OPC UA client sessions. - /// - public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0; - - /// - /// Gets a value indicating whether the OPC UA server has been started and not yet stopped. - /// - public bool IsRunning => _server != null; - - /// - /// Gets the list of opc.tcp base addresses the server is currently listening on. - /// Returns an empty list when the server has not started. - /// - public IReadOnlyList BaseAddresses - { - get - { - var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses; - return addrs != null ? addrs.ToList() : Array.Empty(); - } - } - - /// - /// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri). - /// Returns an empty list when the server has not started. - /// - public IReadOnlyList SecurityPolicies - { - get - { - var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies; - return policies != null ? policies.ToList() : Array.Empty(); - } - } - - /// - /// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate). - /// Returns an empty list when the server has not started. - /// - public IReadOnlyList UserTokenPolicies - { - get - { - var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies; - return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty(); - } - } - - /// - /// Stops the host and releases server resources. - /// - public void Dispose() - { - Stop(); - } - - /// - /// Updates the OPC UA ServiceLevel based on current runtime health. - /// - public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) - { - _server?.UpdateServiceLevel(mxAccessConnected, dbConnected); - } - - /// - /// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured - /// endpoint. - /// - public async Task StartAsync() - { - var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa"; - var applicationUri = _config.ApplicationUri ?? namespaceUri; - - // Resolve configured security profiles - var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles); - foreach (var sp in securityPolicies) - Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode); - - // Build PKI paths - var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "OPC Foundation", "pki"); - var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost"; - - var serverConfig = new ServerConfiguration - { - BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" }, - MaxSessionCount = _config.MaxSessions, - MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms - MinSessionTimeout = 10000, - UserTokenPolicies = BuildUserTokenPolicies() - }; - foreach (var policy in securityPolicies) - serverConfig.SecurityPolicies.Add(policy); - - var secConfig = new SecurityConfiguration - { - ApplicationCertificate = new CertificateIdentifier - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiRoot, "own"), - SubjectName = certSubject - }, - TrustedIssuerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiRoot, "issuer") - }, - TrustedPeerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiRoot, "trusted") - }, - RejectedCertificateStore = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(pkiRoot, "rejected") - }, - AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates, - RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates, - MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize - }; - - var appConfig = new ApplicationConfiguration - { - ApplicationName = _config.ServerName, - ApplicationUri = applicationUri, - ApplicationType = ApplicationType.Server, - ProductUri = namespaceUri, - ServerConfiguration = serverConfig, - SecurityConfiguration = secConfig, - - TransportQuotas = new TransportQuotas - { - OperationTimeout = 120000, - MaxStringLength = 4 * 1024 * 1024, - MaxByteStringLength = 4 * 1024 * 1024, - MaxArrayLength = 65535, - MaxMessageSize = 4 * 1024 * 1024, - MaxBufferSize = 65535, - ChannelLifetime = 600000, - SecurityTokenLifetime = 3600000 - }, - - TraceConfiguration = new TraceConfiguration - { - OutputFilePath = null, - TraceMasks = 0 - } - }; - - await appConfig.Validate(ApplicationType.Server); - - // Hook certificate validation logging - appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation; - - _application = new ApplicationInstance - { - ApplicationName = _config.ServerName, - ApplicationType = ApplicationType.Server, - ApplicationConfiguration = appConfig - }; - - // Check/create application certificate - var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize; - var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths; - var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths); - if (!certOk) - { - Log.Warning("Application certificate check failed, attempting to create..."); - certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths); - } - - _server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, - _config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri, - _alarmObjectFilter, - _mxAccessConfig.RuntimeStatusProbesEnabled, - _mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds, - _mxAccessConfig.RequestTimeoutSeconds, - _historianConfig.RequestTimeoutSeconds); - await _application.Start(_server); - - Log.Information( - "OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})", - _config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri); - } - - private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e) - { - var cert = e.Certificate; - var subject = cert?.Subject ?? "Unknown"; - var thumbprint = cert?.Thumbprint ?? "N/A"; - - if (_securityConfig.AutoAcceptClientCertificates) - { - e.Accept = true; - Log.Warning( - "Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}", - subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd")); - } - else - { - Log.Warning( - "Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}", - e.Error?.StatusCode, subject, thumbprint, e.Accept); - } - } - - /// - /// Stops the OPC UA application instance and releases its in-memory server objects. - /// - public void Stop() - { - try - { - _server?.Stop(); - Log.Information("OPC UA server stopped"); - } - catch (Exception ex) - { - Log.Warning(ex, "Error stopping OPC UA server"); - } - finally - { - _server = null; - _application = null; - } - } - - private UserTokenPolicyCollection BuildUserTokenPolicies() - { - var policies = new UserTokenPolicyCollection(); - if (_authConfig.AllowAnonymous) - policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); - if (_authConfig.Ldap.Enabled || _authProvider != null) - policies.Add(new UserTokenPolicy(UserTokenType.UserName)); - - // X.509 certificate authentication is always available when security is configured - if (_securityConfig.Profiles.Any(p => - !p.Equals("None", StringComparison.OrdinalIgnoreCase))) - policies.Add(new UserTokenPolicy(UserTokenType.Certificate)); - - if (policies.Count == 0) - { - Log.Warning("No authentication methods configured — adding Anonymous as fallback"); - policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); - } - - return policies; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs deleted file mode 100644 index 9a1a797..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/RedundancyModeResolver.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Opc.Ua; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Maps a configured redundancy mode string to the OPC UA enum. - /// - public static class RedundancyModeResolver - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver)); - - /// - /// Resolves the configured mode string to a value. - /// Returns when redundancy is disabled or the mode is unrecognized. - /// - /// The mode string from configuration (e.g., "Warm", "Hot"). - /// Whether redundancy is enabled. - /// The resolved redundancy support mode. - public static RedundancySupport Resolve(string mode, bool enabled) - { - if (!enabled) - return RedundancySupport.None; - - var resolved = (mode ?? "").Trim().ToLowerInvariant() switch - { - "warm" => RedundancySupport.Warm, - "hot" => RedundancySupport.Hot, - _ => RedundancySupport.None - }; - - if (resolved == RedundancySupport.None) - Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", - mode); - - return resolved; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs deleted file mode 100644 index d3c0c08..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Opc.Ua; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Maps configured security profile names to OPC UA instances. - /// - public static class SecurityProfileResolver - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver)); - - private static readonly Dictionary KnownProfiles = - new(StringComparer.OrdinalIgnoreCase) - { - ["None"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.None, - SecurityPolicyUri = SecurityPolicies.None - }, - ["Basic256Sha256-Sign"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.Sign, - SecurityPolicyUri = SecurityPolicies.Basic256Sha256 - }, - ["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.SignAndEncrypt, - SecurityPolicyUri = SecurityPolicies.Basic256Sha256 - }, - ["Aes128_Sha256_RsaOaep-Sign"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.Sign, - SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep - }, - ["Aes128_Sha256_RsaOaep-SignAndEncrypt"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.SignAndEncrypt, - SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep - }, - ["Aes256_Sha256_RsaPss-Sign"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.Sign, - SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss - }, - ["Aes256_Sha256_RsaPss-SignAndEncrypt"] = new ServerSecurityPolicy - { - SecurityMode = MessageSecurityMode.SignAndEncrypt, - SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss - } - }; - - /// - /// Gets the list of valid profile names for validation and documentation. - /// - public static IReadOnlyCollection ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly(); - - /// - /// Resolves the configured profile names to entries. - /// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to None. - /// - /// The profile names from configuration. - /// A deduplicated list of server security policies. - public static List Resolve(IReadOnlyCollection profileNames) - { - var resolved = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var name in profileNames ?? Array.Empty()) - { - if (string.IsNullOrWhiteSpace(name)) - continue; - - var trimmed = name.Trim(); - - if (!seen.Add(trimmed)) - { - Log.Debug("Skipping duplicate security profile: {Profile}", trimmed); - continue; - } - - if (KnownProfiles.TryGetValue(trimmed, out var policy)) - resolved.Add(policy); - else - Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}", - trimmed, string.Join(", ", KnownProfiles.Keys)); - } - - if (resolved.Count == 0) - { - Log.Warning("No valid security profiles configured — falling back to None"); - resolved.Add(KnownProfiles["None"]); - } - - return resolved; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs deleted file mode 100644 index a395e33..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ServiceLevelCalculator.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa -{ - /// - /// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs. - /// - public sealed class ServiceLevelCalculator - { - /// - /// Calculates the current ServiceLevel from a role-adjusted baseline and health state. - /// - /// The role-adjusted baseline (e.g., 200 for primary, 150 for secondary). - /// Whether the MXAccess runtime connection is healthy. - /// Whether the Galaxy repository database is reachable. - /// A ServiceLevel byte between 0 and 255. - public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected) - { - if (!mxAccessConnected && !dbConnected) - return 0; - - var level = baseLevel; - - if (!mxAccessConnected) - level -= 100; - - if (!dbConnected) - level -= 50; - - return (byte)Math.Max(0, Math.Min(level, 255)); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaService.cs deleted file mode 100644 index f38e2d2..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaService.cs +++ /dev/null @@ -1,532 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Serilog; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; -using ZB.MOM.WW.OtOpcUa.Host.Historian; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Host.Status; - -namespace ZB.MOM.WW.OtOpcUa.Host -{ - /// - /// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006) - /// - internal sealed class OpcUaService - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly IUserAuthenticationProvider? _authProviderOverride; - - private readonly AppConfiguration _config; - private readonly IGalaxyRepository? _galaxyRepository; - private readonly bool _hasAuthProviderOverride; - private readonly bool _hasMxAccessClientOverride; - private readonly IMxAccessClient? _mxAccessClientOverride; - private readonly IMxProxy? _mxProxy; - - private CancellationTokenSource? _cts; - private HealthCheckService? _healthCheck; - private IHistorianDataSource? _historianDataSource; - private MxAccessClient? _mxAccessClient; - private IMxAccessClient? _mxAccessClientForWiring; - private StaComThread? _staThread; - - /// - /// Production constructor. Loads configuration from appsettings.json. - /// - public OpcUaService() - { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", false) - .AddJsonFile( - $"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", - true) - .AddEnvironmentVariables() - .Build(); - - _config = new AppConfiguration(); - configuration.GetSection("OpcUa").Bind(_config.OpcUa); - configuration.GetSection("MxAccess").Bind(_config.MxAccess); - configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository); - configuration.GetSection("Dashboard").Bind(_config.Dashboard); - configuration.GetSection("Historian").Bind(_config.Historian); - configuration.GetSection("Authentication").Bind(_config.Authentication); - // Clear the default Profiles list before binding so JSON values replace rather than append - _config.Security.Profiles.Clear(); - configuration.GetSection("Security").Bind(_config.Security); - configuration.GetSection("Redundancy").Bind(_config.Redundancy); - - _mxProxy = new MxProxyAdapter(); - _galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository); - } - - /// - /// Test constructor. Accepts injected dependencies. - /// - /// - /// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard - /// behavior during the test run. - /// - /// The MXAccess proxy substitute used when a test wants to exercise COM-style wiring. - /// - /// The repository substitute that supplies Galaxy hierarchy and deploy metadata for - /// address-space builds. - /// - /// - /// An optional direct MXAccess client substitute that bypasses STA thread setup and - /// COM interop. - /// - /// - /// A value indicating whether the override client should be used instead of - /// creating a client from . - /// - internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository, - IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false, - IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false) - { - _config = config; - _mxProxy = mxProxy; - _galaxyRepository = galaxyRepository; - _mxAccessClientOverride = mxAccessClientOverride; - _hasMxAccessClientOverride = hasMxAccessClientOverride; - _authProviderOverride = authProviderOverride; - _hasAuthProviderOverride = hasAuthProviderOverride; - } - - // Accessors for testing - /// - /// Gets the MXAccess client instance currently wired into the service for test inspection. - /// - internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; - - /// - /// Gets the metrics collector that tracks bridge operation timings during the service lifetime. - /// - internal PerformanceMetrics? Metrics { get; private set; } - - /// - /// Gets the OPC UA server host that owns the runtime endpoint. - /// - internal OpcUaServerHost? ServerHost { get; private set; } - - /// - /// Gets the node manager instance that holds the current Galaxy-derived address space. - /// - internal LmxNodeManager? NodeManagerInstance { get; private set; } - - /// - /// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild. - /// - internal ChangeDetectionService? ChangeDetectionInstance { get; private set; } - - /// - /// Gets the hosted status web server when the dashboard is enabled and successfully bound. - /// Null when Dashboard.Enabled is false or when is true. - /// - internal StatusWebServer? StatusWeb { get; private set; } - - /// - /// Gets a flag indicating that the dashboard was enabled in configuration but failed to bind - /// its HTTP port at startup. The service continues in degraded mode (matching the pattern - /// for other optional subsystems: MxAccess connect, Galaxy DB connect, initial address space - /// build). Surfaced for tests and any external health probe that needs to distinguish - /// "dashboard disabled by config" from "dashboard failed to start". - /// - internal bool DashboardStartFailed { get; private set; } - - /// - /// Gets the dashboard report generator used to assemble operator-facing status snapshots. - /// - internal StatusReportService? StatusReportInstance { get; private set; } - - /// - /// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds. - /// - internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; } - - /// - /// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA - /// address space, and optionally hosting the status dashboard. - /// - public void Start() - { - Log.Information("LmxOpcUa service starting"); - - try - { - // Step 2: Validate config - if (!ConfigurationValidator.ValidateAndLog(_config)) - { - Log.Error("Configuration validation failed"); - throw new InvalidOperationException("Configuration validation failed"); - } - - // Step 3: Register exception handler (SVC-006) - AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; - - // Step 4: Create PerformanceMetrics - _cts = new CancellationTokenSource(); - Metrics = new PerformanceMetrics(); - - // Step 5: Create MxAccessClient → Connect - if (_hasMxAccessClientOverride) - { - // Test path: use injected IMxAccessClient directly (skips STA thread + COM) - _mxAccessClientForWiring = _mxAccessClientOverride; - if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected) - _mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); - } - else if (_mxProxy != null) - { - try - { - _staThread = new StaComThread(); - _staThread.Start(); - _mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics); - try - { - _mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, - "MxAccess connection failed at startup - monitor will continue retrying in the background"); - } - - // Step 6: Start monitor loop even if initial connect failed - _mxAccessClient.StartMonitor(); - } - catch (Exception ex) - { - Log.Warning(ex, "MxAccess initialization failed - continuing without runtime data access"); - _mxAccessClient?.Dispose(); - _mxAccessClient = null; - _staThread?.Dispose(); - _staThread = null; - } - } - - // Step 7: Create GalaxyRepositoryService → TestConnection - GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName }; - - if (_galaxyRepository != null) - { - var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult(); - GalaxyStatsInstance.DbConnected = dbOk; - if (!dbOk) - Log.Warning("Galaxy repository database connection failed — continuing without initial data"); - } - - // Step 8: Create OPC UA server host + node manager - var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? - _mxAccessClientForWiring ?? new NullMxAccessClient(); - if (_config.Historian.Enabled) - { - _historianDataSource = HistorianPluginLoader.TryLoad(_config.Historian); - } - else - { - HistorianPluginLoader.MarkDisabled(); - _historianDataSource = null; - } - IUserAuthenticationProvider? authProvider = null; - if (_hasAuthProviderOverride) - { - authProvider = _authProviderOverride; - } - else if (_config.Authentication.Ldap.Enabled) - { - authProvider = new LdapAuthenticationProvider(_config.Authentication.Ldap); - Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", - _config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, - _config.Authentication.Ldap.BaseDN); - } - - var alarmObjectFilter = new AlarmObjectFilter(_config.OpcUa.AlarmFilter); - if (alarmObjectFilter.Enabled) - Log.Information( - "Alarm object filter compiled with {PatternCount} pattern(s): [{Patterns}]", - alarmObjectFilter.PatternCount, - string.Join(", ", _config.OpcUa.AlarmFilter.ObjectFilters)); - - ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, _historianDataSource, - _config.Authentication, authProvider, _config.Security, _config.Redundancy, alarmObjectFilter, - _config.MxAccess, _config.Historian); - - // Step 9-10: Query hierarchy, start server, build address space - DateTime? initialDeployTime = null; - if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected) - { - try - { - initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter() - .GetResult(); - var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult(); - var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult(); - GalaxyStatsInstance.ObjectCount = hierarchy.Count; - GalaxyStatsInstance.AttributeCount = attributes.Count; - - ServerHost.StartAsync().GetAwaiter().GetResult(); - NodeManagerInstance = ServerHost.NodeManager; - - if (NodeManagerInstance != null) - { - NodeManagerInstance.BuildAddressSpace(hierarchy, attributes); - GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow; - } - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to build initial address space"); - if (!ServerHost.IsRunning) - { - ServerHost.StartAsync().GetAwaiter().GetResult(); - NodeManagerInstance = ServerHost.NodeManager; - } - } - } - else - { - ServerHost.StartAsync().GetAwaiter().GetResult(); - NodeManagerInstance = ServerHost.NodeManager; - } - - // Step 11-12: Change detection wired to rebuild - if (_galaxyRepository != null) - { - ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository, - _config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime); - ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged; - ChangeDetectionInstance.Start(); - } - - // Step 13: Dashboard - _healthCheck = new HealthCheckService(); - StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds); - StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost, - NodeManagerInstance, - _config.Redundancy, _config.OpcUa.ApplicationUri, _config.Historian); - - if (_config.Dashboard.Enabled) - { - var dashboardServer = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port); - if (dashboardServer.Start()) - { - StatusWeb = dashboardServer; - } - else - { - // Degraded mode: StatusWebServer.Start() already logged the underlying exception. - // Dispose the unstarted instance, null out the reference, and flag the failure so - // tests and health probes can observe it. Service startup continues. - Log.Warning("Status dashboard failed to bind on port {Port}; service continues without dashboard", - _config.Dashboard.Port); - dashboardServer.Dispose(); - DashboardStartFailed = true; - } - } - - // Wire ServiceLevel updates from MXAccess health changes - if (_config.Redundancy.Enabled) - effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel; - - // Step 14 - Log.Information("LmxOpcUa service started successfully"); - } - catch (Exception ex) - { - Log.Fatal(ex, "LmxOpcUa service failed to start"); - throw; - } - } - - /// - /// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in - /// shutdown order. - /// - public void Stop() - { - Log.Information("LmxOpcUa service stopping"); - - try - { - _cts?.Cancel(); - ChangeDetectionInstance?.Stop(); - ServerHost?.Stop(); - - if (_mxAccessClient != null) - { - _mxAccessClient.StopMonitor(); - _mxAccessClient.DisconnectAsync().GetAwaiter().GetResult(); - _mxAccessClient.Dispose(); - } - - _staThread?.Dispose(); - _historianDataSource?.Dispose(); - - StatusWeb?.Dispose(); - Metrics?.Dispose(); - ChangeDetectionInstance?.Dispose(); - _cts?.Dispose(); - - AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; - } - catch (Exception ex) - { - Log.Warning(ex, "Error during service shutdown"); - } - - Log.Information("Service shutdown complete"); - } - - private void OnGalaxyChanged() - { - Log.Information("Galaxy change detected — rebuilding address space"); - try - { - if (_galaxyRepository == null || NodeManagerInstance == null) return; - - var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult(); - var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult(); - - NodeManagerInstance.RebuildAddressSpace(hierarchy, attributes); - - if (GalaxyStatsInstance != null) - { - GalaxyStatsInstance.ObjectCount = hierarchy.Count; - GalaxyStatsInstance.AttributeCount = attributes.Count; - GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow; - GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime; - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to rebuild address space"); - } - } - - private void OnMxAccessStateChangedForServiceLevel(object? sender, ConnectionStateChangedEventArgs e) - { - var mxConnected = e.CurrentState == ConnectionState.Connected; - var dbConnected = GalaxyStatsInstance?.DbConnected ?? false; - ServerHost?.UpdateServiceLevel(mxConnected, dbConnected); - Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected); - } - - private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) - { - Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", - e.IsTerminating); - } - - /// - /// Triggers an address space rebuild from the current Galaxy repository data. For testing. - /// - internal void TriggerRebuild() - { - OnGalaxyChanged(); - } - } - - /// - /// Null implementation of IMxAccessClient for when MXAccess is not available. - /// - internal sealed class NullMxAccessClient : IMxAccessClient - { - /// - /// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity. - /// - public ConnectionState State => ConnectionState.Disconnected; - - /// - /// Gets the active subscription count, which is always zero for the null runtime client. - /// - public int ActiveSubscriptionCount => 0; - - /// - /// Gets the reconnect count, which is always zero because the null client never establishes a session. - /// - public int ReconnectCount => 0; - - /// - /// Occurs when the runtime connection state changes. The null client never raises this event. - /// - public event EventHandler? ConnectionStateChanged; - - /// - /// Occurs when a subscribed tag value changes. The null client never raises this event. - /// - public event Action? OnTagValueChanged; - - /// - /// Completes immediately because no live runtime connection is available or required. - /// - /// A cancellation token that is ignored by the null implementation. - public Task ConnectAsync(CancellationToken ct = default) - { - return Task.CompletedTask; - } - - /// - /// Completes immediately because there is no live runtime session to close. - /// - public Task DisconnectAsync() - { - return Task.CompletedTask; - } - - /// - /// Completes immediately because the null client does not subscribe to live Galaxy attributes. - /// - /// The tag reference that would have been subscribed. - /// The callback that would have received runtime value changes. - public Task SubscribeAsync(string fullTagReference, Action callback) - { - return Task.CompletedTask; - } - - /// - /// Completes immediately because the null client does not maintain runtime subscriptions. - /// - /// The tag reference that would have been unsubscribed. - public Task UnsubscribeAsync(string fullTagReference) - { - return Task.CompletedTask; - } - - /// - /// Returns a bad-quality value because no live runtime source exists. - /// - /// The tag reference that would have been read from the runtime. - /// A cancellation token that is ignored by the null implementation. - /// A bad-quality VTQ indicating that runtime data is unavailable. - public Task ReadAsync(string fullTagReference, CancellationToken ct = default) - { - return Task.FromResult(Vtq.Bad()); - } - - /// - /// Rejects writes because there is no live runtime endpoint behind the null client. - /// - /// The tag reference that would have been written. - /// The value that would have been sent to the runtime. - /// A cancellation token that is ignored by the null implementation. - /// A completed task returning . - public Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) - { - return Task.FromResult(false); - } - - /// - /// Releases the null client. No unmanaged runtime resources exist. - /// - public void Dispose() - { - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaServiceBuilder.cs deleted file mode 100644 index f10dc4c..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/OpcUaServiceBuilder.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Host -{ - /// - /// Fluent builder for constructing OpcUaService with dependency overrides. - /// Used by integration tests to substitute fakes for COM/DB components. - /// - internal class OpcUaServiceBuilder - { - private IUserAuthenticationProvider? _authProvider; - private bool _authProviderSet; - private AppConfiguration _config = new(); - private IGalaxyRepository? _galaxyRepository; - private bool _galaxyRepositorySet; - private IMxAccessClient? _mxAccessClient; - private bool _mxAccessClientSet; - private IMxProxy? _mxProxy; - private bool _mxProxySet; - - /// - /// Replaces the default service configuration used by the test host. - /// - /// The full configuration snapshot to inject into the service under test. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithConfig(AppConfiguration config) - { - _config = config; - return this; - } - - /// - /// Sets the OPC UA port used by the test host so multiple integration runs can coexist. - /// - /// The TCP port to expose for the test server. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithOpcUaPort(int port) - { - _config.OpcUa.Port = port; - return this; - } - - /// - /// Sets the Galaxy name represented by the test address space. - /// - /// The Galaxy name to expose through OPC UA and diagnostics. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithGalaxyName(string name) - { - _config.OpcUa.GalaxyName = name; - return this; - } - - /// - /// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path. - /// - /// The proxy fake or stub to supply to the service. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy) - { - _mxProxy = proxy; - _mxProxySet = true; - return this; - } - - /// - /// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata. - /// - /// The repository fake or stub to supply to the service. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository) - { - _galaxyRepository = repository; - _galaxyRepositorySet = true; - return this; - } - - /// - /// Override the MxAccessClient directly, skipping STA thread and COM interop entirely. - /// When set, the service will use this client instead of creating one from IMxProxy. - /// - /// The direct MXAccess client substitute to inject into the service. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client) - { - _mxAccessClient = client; - _mxAccessClientSet = true; - return this; - } - - /// - /// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests. - /// - /// The object hierarchy to expose through the test OPC UA namespace. - /// The attribute rows to attach to the hierarchy. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithHierarchy(List hierarchy, List attributes) - { - if (!_galaxyRepositorySet) - { - var fake = new FakeBuilderGalaxyRepository(); - _galaxyRepository = fake; - _galaxyRepositorySet = true; - } - - if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo) - { - fakeRepo.Hierarchy = hierarchy; - fakeRepo.Attributes = attributes; - } - - return this; - } - - /// - /// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener. - /// - /// The current builder so additional overrides can be chained. - /// - /// Injects a custom authentication provider for tests that need deterministic role resolution. - /// - public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider) - { - _authProvider = provider; - _authProviderSet = true; - return this; - } - - /// - /// Sets the authentication configuration for the test host. - /// - public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig) - { - _config.Authentication = authConfig; - return this; - } - - public OpcUaServiceBuilder DisableDashboard() - { - _config.Dashboard.Enabled = false; - return this; - } - - /// - /// Sets the redundancy configuration for the test host. - /// - /// The redundancy configuration to inject. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithRedundancy(RedundancyConfiguration redundancy) - { - _config.Redundancy = redundancy; - return this; - } - - /// - /// Sets the application URI for the test host, distinct from the namespace URI. - /// - /// The unique application URI for this server instance. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithApplicationUri(string applicationUri) - { - _config.OpcUa.ApplicationUri = applicationUri; - return this; - } - - /// - /// Sets the security profile configuration for the test host. - /// - /// The security profile configuration to inject. - /// The current builder so additional overrides can be chained. - /// - /// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path. - /// - /// Whether alarm tracking should be enabled. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithAlarmTracking(bool enabled) - { - _config.OpcUa.AlarmTrackingEnabled = enabled; - return this; - } - - /// - /// Configures the template-based alarm object filter for integration tests. - /// - /// Zero or more wildcard patterns. Empty → filter disabled. - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder WithAlarmFilter(params string[] filters) - { - _config.OpcUa.AlarmFilter = new AlarmFilterConfiguration - { - ObjectFilters = filters.ToList() - }; - return this; - } - - public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security) - { - _config.Security = security; - return this; - } - - /// - /// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations. - /// - /// The current builder so additional overrides can be chained. - public OpcUaServiceBuilder DisableChangeDetection() - { - _config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue; - return this; - } - - /// - /// Creates an using the accumulated test doubles and configuration overrides. - /// - /// A service instance ready for integration-style testing. - public OpcUaService Build() - { - return new OpcUaService( - _config, - _mxProxySet ? _mxProxy : null, - _galaxyRepositorySet ? _galaxyRepository : null, - _mxAccessClientSet ? _mxAccessClient : null, - _mxAccessClientSet, - _authProviderSet ? _authProvider : null, - _authProviderSet); - } - - /// - /// Internal fake repository used by WithHierarchy for convenience. - /// - private class FakeBuilderGalaxyRepository : IGalaxyRepository - { - /// - /// Gets or sets the hierarchy rows that the fake repository returns to the service. - /// - public List Hierarchy { get; set; } = new(); - - /// - /// Gets or sets the attribute rows that the fake repository returns to the service. - /// - public List Attributes { get; set; } = new(); - - /// - /// Occurs when the fake repository wants to simulate a Galaxy deploy change. - /// - public event Action? OnGalaxyChanged; - - /// - /// Returns the seeded hierarchy rows for address-space construction. - /// - /// A cancellation token that is ignored by the in-memory fake. - /// The configured hierarchy rows. - public Task> GetHierarchyAsync(CancellationToken ct = default) - { - return Task.FromResult(Hierarchy); - } - - /// - /// Returns the seeded attribute rows for address-space construction. - /// - /// A cancellation token that is ignored by the in-memory fake. - /// The configured attribute rows. - public Task> GetAttributesAsync(CancellationToken ct = default) - { - return Task.FromResult(Attributes); - } - - /// - /// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against. - /// - /// A cancellation token that is ignored by the in-memory fake. - /// The current UTC time. - public Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - return Task.FromResult(DateTime.UtcNow); - } - - /// - /// Reports a healthy repository connection for builder-based test setups. - /// - /// A cancellation token that is ignored by the in-memory fake. - /// A completed task returning . - public Task TestConnectionAsync(CancellationToken ct = default) - { - return Task.FromResult(true); - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Program.cs deleted file mode 100644 index 897ae8f..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using Serilog; -using Topshelf; - -namespace ZB.MOM.WW.OtOpcUa.Host -{ - internal static class Program - { - private static int Main(string[] args) - { - // Set working directory to exe location so relative log paths resolve correctly - // (Windows services default to System32) - Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory; - - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .WriteTo.Console() - .WriteTo.File( - "logs/lmxopcua-.log", - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 31) - .CreateLogger(); - - try - { - var exitCode = HostFactory.Run(host => - { - host.UseSerilog(); - - host.Service(svc => - { - svc.ConstructUsing(() => new OpcUaService()); - svc.WhenStarted(s => s.Start()); - svc.WhenStopped(s => s.Stop()); - }); - - host.SetServiceName("OtOpcUa"); - host.SetDisplayName("LMX OPC UA Server"); - host.SetDescription("OPC UA server exposing System Platform Galaxy tags via MXAccess."); - host.RunAsLocalSystem(); - host.StartAutomatically(); - }); - - return (int)exitCode; - } - catch (Exception ex) - { - Log.Fatal(ex, "Host terminated unexpectedly"); - return 1; - } - finally - { - Log.CloseAndFlush(); - } - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Status/HealthCheckService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Status/HealthCheckService.cs deleted file mode 100644 index 46f65ac..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Status/HealthCheckService.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Linq; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Host.Status -{ - /// - /// Determines health status based on connection state and operation success rates. (DASH-003) - /// - public class HealthCheckService - { - /// - /// Evaluates bridge health from runtime connectivity, recorded performance metrics, and optional - /// historian/alarm integration state. - /// - /// The current MXAccess connection state. - /// The recorded performance metrics, if available. - /// Optional historian integration snapshot; pass null to skip historian health rules. - /// Optional alarm integration snapshot; pass null to skip alarm health rules. - /// A dashboard health snapshot describing the current service condition. - public HealthInfo CheckHealth( - ConnectionState connectionState, - PerformanceMetrics? metrics, - HistorianStatusInfo? historian = null, - AlarmStatusInfo? alarms = null, - RuntimeStatusInfo? runtime = null) - { - // Rule 1: Not connected → Unhealthy - if (connectionState != ConnectionState.Connected) - return new HealthInfo - { - Status = "Unhealthy", - Message = $"MXAccess not connected (state: {connectionState})", - Color = "red" - }; - - // Rule 2b: Historian enabled but plugin did not load → Degraded - if (historian != null && historian.Enabled && historian.PluginStatus != "Loaded") - return new HealthInfo - { - Status = "Degraded", - Message = - $"Historian enabled but plugin status is {historian.PluginStatus}: {historian.PluginError ?? "(no error)"}", - Color = "yellow" - }; - - // Rule 2b2: Historian plugin loaded but queries are failing consecutively → Degraded. - // Threshold of 3 avoids flagging a single transient blip; anything beyond that means - // the SDK is in a broken state that the reconnect loop isn't recovering from. - if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded" - && historian.ConsecutiveFailures >= 3) - return new HealthInfo - { - Status = "Degraded", - Message = - $"Historian plugin has {historian.ConsecutiveFailures} consecutive query failures: " + - $"{historian.LastQueryError ?? "(no error)"}", - Color = "yellow" - }; - - // Rule 2b3: Historian cluster has nodes in cooldown → Degraded (partial cluster). - // Only surfaces when the operator actually configured a multi-node cluster. - if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded" - && historian.NodeCount > 1 && historian.HealthyNodeCount < historian.NodeCount) - return new HealthInfo - { - Status = "Degraded", - Message = - $"Historian cluster has {historian.HealthyNodeCount} of {historian.NodeCount} " + - "nodes healthy — one or more nodes are in failure cooldown", - Color = "yellow" - }; - - // Rule 2 / 2c: Success rate too low for any recorded operation - if (metrics != null) - { - var stats = metrics.GetStatistics(); - foreach (var kvp in stats) - { - var isHistoryOp = kvp.Key.StartsWith("HistoryRead", System.StringComparison.OrdinalIgnoreCase); - // History reads are rare; drop the sample threshold so a stuck historian surfaces quickly. - var sampleThreshold = isHistoryOp ? 10 : 100; - if (kvp.Value.TotalCount > sampleThreshold && kvp.Value.SuccessRate < 0.5) - return new HealthInfo - { - Status = "Degraded", - Message = - $"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)", - Color = "yellow" - }; - } - } - - // Rule 2d: Any alarm acknowledge write has failed since startup → Degraded (latched) - if (alarms != null && alarms.TrackingEnabled && alarms.AckWriteFailures > 0) - return new HealthInfo - { - Status = "Degraded", - Message = $"Alarm acknowledge writes have failed ({alarms.AckWriteFailures} total)", - Color = "yellow" - }; - - // Rule 2e: Any Galaxy runtime host (Platform/AppEngine) is Stopped → Degraded. - // Runs after the transport check so that MxAccess-disconnected remains Unhealthy via - // Rule 1 without also firing the runtime rule — avoids a double-message when the - // transport is the root cause of every host going Unknown/Stopped. - if (runtime != null && runtime.StoppedCount > 0) - { - var stoppedNames = string.Join(", ", - runtime.Hosts.Where(h => h.State == Domain.GalaxyRuntimeState.Stopped).Select(h => h.ObjectName)); - return new HealthInfo - { - Status = "Degraded", - Message = - $"Galaxy runtime has {runtime.StoppedCount} of {runtime.Total} host(s) stopped: {stoppedNames}", - Color = "yellow" - }; - } - - // Rule 3: All good - return new HealthInfo - { - Status = "Healthy", - Message = "All systems operational", - Color = "green" - }; - } - - /// - /// Determines whether the bridge should currently be treated as healthy. - /// - /// The current MXAccess connection state. - /// The recorded performance metrics, if available. - /// when the bridge is not unhealthy; otherwise, . - public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics) - { - var health = CheckHealth(connectionState, metrics); - return health.Status != "Unhealthy"; - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusData.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusData.cs deleted file mode 100644 index cd62f2a..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusData.cs +++ /dev/null @@ -1,570 +0,0 @@ -using System; -using System.Collections.Generic; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Host.Status -{ - /// - /// DTO containing all dashboard data. (DASH-001 through DASH-009) - /// - public class StatusData - { - /// - /// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard. - /// - public ConnectionInfo Connection { get; set; } = new(); - - /// - /// Gets or sets the overall health state communicated to operators. - /// - public HealthInfo Health { get; set; } = new(); - - /// - /// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining. - /// - public SubscriptionInfo Subscriptions { get; set; } = new(); - - /// - /// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts. - /// - public GalaxyInfo Galaxy { get; set; } = new(); - - /// - /// Gets or sets MXAccess data change dispatch queue metrics. - /// - public DataChangeInfo DataChange { get; set; } = new(); - - /// - /// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency. - /// - public Dictionary Operations { get; set; } = new(); - - /// - /// Gets or sets the historian integration status (plugin load outcome, server target). - /// - public HistorianStatusInfo Historian { get; set; } = new(); - - /// - /// Gets or sets the alarm integration status and event counters. - /// - public AlarmStatusInfo Alarms { get; set; } = new(); - - /// - /// Gets or sets the redundancy state when redundancy is enabled. - /// - public RedundancyInfo? Redundancy { get; set; } - - /// - /// Gets or sets the listening OPC UA endpoints and active security profiles. - /// - public EndpointsInfo Endpoints { get; set; } = new(); - - /// - /// Gets or sets the Galaxy runtime host state (Platforms + AppEngines). - /// - public RuntimeStatusInfo RuntimeStatus { get; set; } = new(); - - /// - /// Gets or sets footer details such as the snapshot timestamp and service version. - /// - public FooterInfo Footer { get; set; } = new(); - } - - /// - /// Dashboard model summarizing per-host Galaxy runtime state. - /// - public class RuntimeStatusInfo - { - /// - /// Gets or sets the total number of tracked runtime hosts ($WinPlatform + $AppEngine). - /// - public int Total { get; set; } - - /// - /// Gets or sets the count of hosts currently reported Running. - /// - public int RunningCount { get; set; } - - /// - /// Gets or sets the count of hosts currently reported Stopped. - /// - public int StoppedCount { get; set; } - - /// - /// Gets or sets the count of hosts whose state is still Unknown (either awaiting initial - /// probe resolution or transported-through-disconnected). - /// - public int UnknownCount { get; set; } - - /// - /// Gets or sets the per-host state in stable alphabetical order. - /// - public List Hosts { get; set; } = new(); - } - - /// - /// Dashboard model describing the OPC UA server's listening endpoints and active security profiles. - /// - public class EndpointsInfo - { - /// - /// Gets or sets the list of opc.tcp base addresses the server is listening on. - /// - public List BaseAddresses { get; set; } = new(); - - /// - /// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate). - /// - public List UserTokenPolicies { get; set; } = new(); - - /// - /// Gets or sets the active security profiles reported to clients. - /// - public List SecurityProfiles { get; set; } = new(); - } - - /// - /// Dashboard model for a single configured OPC UA server security profile. - /// - public class SecurityProfileInfo - { - /// - /// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256). - /// - public string PolicyUri { get; set; } = ""; - - /// - /// Gets or sets the short policy name extracted from the policy URI. - /// - public string PolicyName { get; set; } = ""; - - /// - /// Gets or sets the message security mode (None, Sign, SignAndEncrypt). - /// - public string SecurityMode { get; set; } = ""; - } - - /// - /// Dashboard model for current runtime connection details. - /// - public class ConnectionInfo - { - /// - /// Gets or sets the current MXAccess connection state shown to operators. - /// - public string State { get; set; } = "Disconnected"; - - /// - /// Gets or sets how many reconnect attempts have occurred since the service started. - /// - public int ReconnectCount { get; set; } - - /// - /// Gets or sets the number of active OPC UA sessions connected to the bridge. - /// - public int ActiveSessions { get; set; } - } - - /// - /// Dashboard model for the overall health banner. - /// - public class HealthInfo - { - /// - /// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy. - /// - public string Status { get; set; } = "Unknown"; - - /// - /// Gets or sets the operator-facing explanation for the current health state. - /// - public string Message { get; set; } = ""; - - /// - /// Gets or sets the color token used by the dashboard UI to render the health banner. - /// - public string Color { get; set; } = "gray"; - } - - /// - /// Dashboard model for subscription load. - /// - public class SubscriptionInfo - { - /// - /// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA. - /// This total includes bridge-owned runtime status probes; see for the - /// subset attributable to probes. - /// - public int ActiveCount { get; set; } - - /// - /// Gets or sets the count of bridge-owned runtime status probes included in - /// . Surfaced on the dashboard so operators can distinguish probe - /// overhead from client-driven subscription load. - /// - public int ProbeCount { get; set; } - } - - /// - /// Dashboard model for Galaxy metadata and rebuild status. - /// - public class GalaxyInfo - { - /// - /// Gets or sets the Galaxy name currently being bridged into OPC UA. - /// - public string GalaxyName { get; set; } = ""; - - /// - /// Gets or sets a value indicating whether the repository database is currently reachable. - /// - public bool DbConnected { get; set; } - - /// - /// Gets or sets the most recent deploy timestamp observed in the Galaxy repository. - /// - public DateTime? LastDeployTime { get; set; } - - /// - /// Gets or sets the number of Galaxy objects currently represented in the address space. - /// - public int ObjectCount { get; set; } - - /// - /// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables. - /// - public int AttributeCount { get; set; } - - /// - /// Gets or sets the UTC timestamp of the last completed address-space rebuild. - /// - public DateTime? LastRebuildTime { get; set; } - } - - /// - /// Dashboard model for MXAccess data change dispatch metrics. - /// - public class DataChangeInfo - { - /// - /// Gets or sets the rate of MXAccess data change events received per second. - /// - public double EventsPerSecond { get; set; } - - /// - /// Gets or sets the average number of items processed per dispatch cycle. - /// - public double AvgBatchSize { get; set; } - - /// - /// Gets or sets the number of items currently waiting in the dispatch queue. - /// - public int PendingItems { get; set; } - - /// - /// Gets or sets the total MXAccess data change events received since startup. - /// - public long TotalEvents { get; set; } - } - - /// - /// Dashboard model for the Wonderware historian integration (runtime-loaded plugin). - /// - public class HistorianStatusInfo - { - /// - /// Gets or sets a value indicating whether historian support is enabled in configuration. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the most recent plugin load outcome as a string. - /// Values: Disabled, NotFound, LoadFailed, Loaded. - /// - public string PluginStatus { get; set; } = "Disabled"; - - /// - /// Gets or sets the error message from the last load attempt when is LoadFailed. - /// - public string? PluginError { get; set; } - - /// - /// Gets or sets the absolute path the loader probed for the plugin assembly. - /// - public string PluginPath { get; set; } = ""; - - /// - /// Gets or sets the configured historian server hostname. - /// - public string ServerName { get; set; } = ""; - - /// - /// Gets or sets the configured historian TCP port. - /// - public int Port { get; set; } - - /// - /// Gets or sets the total number of historian read queries attempted since startup. - /// - public long QueryTotal { get; set; } - - /// - /// Gets or sets the number of historian queries that completed without an exception. - /// - public long QuerySuccesses { get; set; } - - /// - /// Gets or sets the number of historian queries that raised an exception. - /// - public long QueryFailures { get; set; } - - /// - /// Gets or sets the number of consecutive failures since the last successful query. - /// - public int ConsecutiveFailures { get; set; } - - /// - /// Gets or sets the UTC timestamp of the last successful query. - /// - public DateTime? LastSuccessTime { get; set; } - - /// - /// Gets or sets the UTC timestamp of the last query failure. - /// - public DateTime? LastFailureTime { get; set; } - - /// - /// Gets or sets the exception message from the most recent failure. - /// - public string? LastQueryError { get; set; } - - /// - /// Gets or sets a value indicating whether the plugin currently holds an open process-path - /// SDK connection. - /// - public bool ProcessConnectionOpen { get; set; } - - /// - /// Gets or sets a value indicating whether the plugin currently holds an open event-path - /// SDK connection. - /// - public bool EventConnectionOpen { get; set; } - - /// - /// Gets or sets the total number of configured historian cluster nodes. - /// - public int NodeCount { get; set; } - - /// - /// Gets or sets the number of cluster nodes currently eligible for new connections - /// (i.e., not in failure cooldown). - /// - public int HealthyNodeCount { get; set; } - - /// - /// Gets or sets the node currently serving process (historical value) queries, or null - /// when no process connection is open. - /// - public string? ActiveProcessNode { get; set; } - - /// - /// Gets or sets the node currently serving event (alarm history) queries, or null when - /// no event connection is open. - /// - public string? ActiveEventNode { get; set; } - - /// - /// Gets or sets the per-node cluster state in configuration order. - /// - public List Nodes { get; set; } = new(); - } - - /// - /// Dashboard model for alarm integration health and event counters. - /// - public class AlarmStatusInfo - { - /// - /// Gets or sets a value indicating whether alarm condition tracking is enabled in configuration. - /// - public bool TrackingEnabled { get; set; } - - /// - /// Gets or sets the number of distinct alarm conditions currently tracked. - /// - public int ConditionCount { get; set; } - - /// - /// Gets or sets the number of alarms currently in the InAlarm=true state. - /// - public int ActiveAlarmCount { get; set; } - - /// - /// Gets or sets the total number of InAlarm transitions observed since startup. - /// - public long TransitionCount { get; set; } - - /// - /// Gets or sets the total number of alarm acknowledgement transitions observed since startup. - /// - public long AckEventCount { get; set; } - - /// - /// Gets or sets the total number of alarm acknowledgement MXAccess writes that have failed since startup. - /// - public long AckWriteFailures { get; set; } - - /// - /// Gets or sets a value indicating whether the template-based alarm object filter is active. - /// - public bool FilterEnabled { get; set; } - - /// - /// Gets or sets the number of compiled alarm filter patterns. - /// - public int FilterPatternCount { get; set; } - - /// - /// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build. - /// - public int FilterIncludedObjectCount { get; set; } - - /// - /// Gets or sets the raw alarm filter patterns exactly as configured, for dashboard display. - /// - public List FilterPatterns { get; set; } = new(); - } - - /// - /// Dashboard model for redundancy state. Only populated when redundancy is enabled. - /// - public class RedundancyInfo - { - /// - /// Gets or sets whether redundancy is enabled. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the redundancy mode (e.g., "Warm", "Hot"). - /// - public string Mode { get; set; } = ""; - - /// - /// Gets or sets this instance's role ("Primary" or "Secondary"). - /// - public string Role { get; set; } = ""; - - /// - /// Gets or sets the current ServiceLevel byte. - /// - public byte ServiceLevel { get; set; } - - /// - /// Gets or sets this instance's ApplicationUri. - /// - public string ApplicationUri { get; set; } = ""; - - /// - /// Gets or sets the list of all server URIs in the redundant set. - /// - public List ServerUris { get; set; } = new(); - } - - /// - /// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state. - /// - public class HealthEndpointData - { - /// - /// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy. - /// - public string Status { get; set; } = "Unknown"; - - /// - /// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled. - /// - public byte ServiceLevel { get; set; } - - /// - /// Gets or sets whether redundancy is enabled. - /// - public bool RedundancyEnabled { get; set; } - - /// - /// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled. - /// - public string? RedundancyRole { get; set; } - - /// - /// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled. - /// - public string? RedundancyMode { get; set; } - - /// - /// Gets or sets the per-component health breakdown. - /// - public ComponentHealth Components { get; set; } = new(); - - /// - /// Gets or sets the server uptime since the health endpoint was initialized. - /// - public string Uptime { get; set; } = ""; - - /// - /// Gets or sets the UTC timestamp of this health snapshot. - /// - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - } - - /// - /// Per-component health breakdown for the health endpoint. - /// - public class ComponentHealth - { - /// - /// Gets or sets MXAccess runtime connectivity status. - /// - public string MxAccess { get; set; } = "Disconnected"; - - /// - /// Gets or sets Galaxy repository database connectivity status. - /// - public string Database { get; set; } = "Disconnected"; - - /// - /// Gets or sets OPC UA server status. - /// - public string OpcUaServer { get; set; } = "Stopped"; - - /// - /// Gets or sets the historian plugin status. - /// Values: Disabled, NotFound, LoadFailed, Loaded. - /// - public string Historian { get; set; } = "Disabled"; - - /// - /// Gets or sets whether alarm condition tracking is enabled. - /// Values: Disabled, Enabled. - /// - public string Alarms { get; set; } = "Disabled"; - } - - /// - /// Dashboard model for the status page footer. - /// - public class FooterInfo - { - /// - /// Gets or sets the UTC time when the status snapshot was generated. - /// - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets the service version displayed to operators for support and traceability. - /// - public string Version { get; set; } = ""; - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusReportService.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusReportService.cs deleted file mode 100644 index 8bc64ca..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusReportService.cs +++ /dev/null @@ -1,644 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; -using System.Text.Json; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; -using ZB.MOM.WW.OtOpcUa.Host.Historian; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Host.Status -{ - /// - /// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009) - /// - public class StatusReportService - { - private readonly HealthCheckService _healthCheck; - private readonly int _refreshIntervalSeconds; - private readonly DateTime _startTime = DateTime.UtcNow; - private string? _applicationUri; - private GalaxyRepositoryStats? _galaxyStats; - private PerformanceMetrics? _metrics; - - private HistorianConfiguration? _historianConfig; - private IMxAccessClient? _mxAccessClient; - private LmxNodeManager? _nodeManager; - private RedundancyConfiguration? _redundancyConfig; - private OpcUaServerHost? _serverHost; - - /// - /// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh - /// interval. - /// - /// The health-check component used to derive the overall dashboard health status. - /// The HTML auto-refresh interval, in seconds, for the dashboard page. - public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds) - { - _healthCheck = healthCheck; - _refreshIntervalSeconds = refreshIntervalSeconds; - } - - /// - /// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots. - /// - /// The runtime client whose connection and subscription state should be reported. - /// The performance metrics collector whose operation statistics should be reported. - /// The Galaxy repository statistics to surface on the dashboard. - /// The OPC UA server host whose active session count should be reported. - /// - /// The node manager whose queue depth and MXAccess event throughput should be surfaced on the - /// dashboard. - /// - public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics, - GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost, - LmxNodeManager? nodeManager = null, - RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null, - HistorianConfiguration? historianConfig = null) - { - _mxAccessClient = mxAccessClient; - _metrics = metrics; - _galaxyStats = galaxyStats; - _serverHost = serverHost; - _nodeManager = nodeManager; - _redundancyConfig = redundancyConfig; - _applicationUri = applicationUri; - _historianConfig = historianConfig; - } - - /// - /// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers. - /// - /// The current dashboard status data for the bridge. - public StatusData GetStatusData() - { - var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected; - var historianInfo = BuildHistorianStatusInfo(); - var alarmInfo = BuildAlarmStatusInfo(); - - return new StatusData - { - Connection = new ConnectionInfo - { - State = connectionState.ToString(), - ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0, - ActiveSessions = _serverHost?.ActiveSessionCount ?? 0 - }, - Health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo, BuildRuntimeStatusInfo()), - Subscriptions = new SubscriptionInfo - { - ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0, - ProbeCount = _nodeManager?.ActiveRuntimeProbeCount ?? 0 - }, - Galaxy = new GalaxyInfo - { - GalaxyName = _galaxyStats?.GalaxyName ?? "", - DbConnected = _galaxyStats?.DbConnected ?? false, - LastDeployTime = _galaxyStats?.LastDeployTime, - ObjectCount = _galaxyStats?.ObjectCount ?? 0, - AttributeCount = _galaxyStats?.AttributeCount ?? 0, - LastRebuildTime = _galaxyStats?.LastRebuildTime - }, - DataChange = new DataChangeInfo - { - EventsPerSecond = _nodeManager?.MxChangeEventsPerSecond ?? 0, - AvgBatchSize = _nodeManager?.AverageDispatchBatchSize ?? 0, - PendingItems = _nodeManager?.PendingDataChangeCount ?? 0, - TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0 - }, - Operations = _metrics?.GetStatistics() ?? new Dictionary(), - Historian = historianInfo, - Alarms = alarmInfo, - Redundancy = BuildRedundancyInfo(), - Endpoints = BuildEndpointsInfo(), - RuntimeStatus = BuildRuntimeStatusInfo(), - Footer = new FooterInfo - { - Timestamp = DateTime.UtcNow, - Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0" - } - }; - } - - private HistorianStatusInfo BuildHistorianStatusInfo() - { - var outcome = HistorianPluginLoader.LastOutcome; - var health = _nodeManager?.HistorianHealth; - return new HistorianStatusInfo - { - Enabled = _historianConfig?.Enabled ?? false, - PluginStatus = outcome.Status.ToString(), - PluginError = outcome.Error, - PluginPath = outcome.PluginPath, - ServerName = _historianConfig?.ServerName ?? "", - Port = _historianConfig?.Port ?? 0, - QueryTotal = health?.TotalQueries ?? 0, - QuerySuccesses = health?.TotalSuccesses ?? 0, - QueryFailures = health?.TotalFailures ?? 0, - ConsecutiveFailures = health?.ConsecutiveFailures ?? 0, - LastSuccessTime = health?.LastSuccessTime, - LastFailureTime = health?.LastFailureTime, - LastQueryError = health?.LastError, - ProcessConnectionOpen = health?.ProcessConnectionOpen ?? false, - EventConnectionOpen = health?.EventConnectionOpen ?? false, - NodeCount = health?.NodeCount ?? 0, - HealthyNodeCount = health?.HealthyNodeCount ?? 0, - ActiveProcessNode = health?.ActiveProcessNode, - ActiveEventNode = health?.ActiveEventNode, - Nodes = health?.Nodes ?? new List() - }; - } - - private AlarmStatusInfo BuildAlarmStatusInfo() - { - return new AlarmStatusInfo - { - TrackingEnabled = _nodeManager?.AlarmTrackingEnabled ?? false, - ConditionCount = _nodeManager?.AlarmConditionCount ?? 0, - ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0, - TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0, - AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0, - AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0, - FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false, - FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0, - FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0, - FilterPatterns = _nodeManager?.AlarmFilterPatterns?.ToList() ?? new List() - }; - } - - private EndpointsInfo BuildEndpointsInfo() - { - var info = new EndpointsInfo(); - if (_serverHost == null) - return info; - - info.BaseAddresses = _serverHost.BaseAddresses.ToList(); - info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList(); - foreach (var policy in _serverHost.SecurityPolicies) - { - var uri = policy.SecurityPolicyUri ?? ""; - var hashIdx = uri.LastIndexOf('#'); - var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri; - info.SecurityProfiles.Add(new SecurityProfileInfo - { - PolicyUri = uri, - PolicyName = name, - SecurityMode = policy.SecurityMode.ToString() - }); - } - - return info; - } - - private RuntimeStatusInfo BuildRuntimeStatusInfo() - { - var hosts = _nodeManager?.RuntimeStatuses?.ToList() ?? new List(); - var info = new RuntimeStatusInfo - { - Total = hosts.Count, - Hosts = hosts - }; - foreach (var host in hosts) - { - switch (host.State) - { - case GalaxyRuntimeState.Running: info.RunningCount++; break; - case GalaxyRuntimeState.Stopped: info.StoppedCount++; break; - default: info.UnknownCount++; break; - } - } - return info; - } - - private RedundancyInfo? BuildRedundancyInfo() - { - if (_redundancyConfig == null || !_redundancyConfig.Enabled) - return null; - - var mxConnected = (_mxAccessClient?.State ?? ConnectionState.Disconnected) == ConnectionState.Connected; - var dbConnected = _galaxyStats?.DbConnected ?? false; - var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); - var baseLevel = isPrimary - ? _redundancyConfig.ServiceLevelBase - : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); - var calculator = new ServiceLevelCalculator(); - - return new RedundancyInfo - { - Enabled = true, - Mode = _redundancyConfig.Mode, - Role = _redundancyConfig.Role, - ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected), - ApplicationUri = _applicationUri ?? "", - ServerUris = new List(_redundancyConfig.ServerUris) - }; - } - - /// - /// Generates the operator-facing HTML dashboard for the current bridge status. - /// - /// An HTML document containing the latest dashboard snapshot. - public string GenerateHtml() - { - var data = GetStatusData(); - var sb = new StringBuilder(); - - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine($""); - sb.AppendLine("LmxOpcUa Status"); - sb.AppendLine(""); - sb.AppendLine( - $"

LmxOpcUa Status Dashboardv{WebUtility.HtmlEncode(data.Footer.Version)}

"); - - // Connection panel - var connColor = data.Connection.State == "Connected" ? "green" : - data.Connection.State == "Connecting" ? "yellow" : "red"; - sb.AppendLine($"

Connection

"); - sb.AppendLine( - $"

State: {data.Connection.State} | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}

"); - sb.AppendLine("
"); - - // Health panel - sb.AppendLine($"

Health

"); - sb.AppendLine($"

Status: {data.Health.Status} — {data.Health.Message}

"); - sb.AppendLine("
"); - - // Endpoints panel (exposed URLs + security profiles) - var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray"; - sb.AppendLine($"

Endpoints

"); - if (data.Endpoints.BaseAddresses.Count == 0) - { - sb.AppendLine("

No endpoints — OPC UA server not started.

"); - } - else - { - sb.AppendLine("

Base Addresses:

    "); - foreach (var addr in data.Endpoints.BaseAddresses) - sb.AppendLine($"
  • {WebUtility.HtmlEncode(addr)}
  • "); - sb.AppendLine("
"); - - sb.AppendLine("

Security Profiles:

"); - sb.AppendLine(""); - foreach (var profile in data.Endpoints.SecurityProfiles) - { - sb.AppendLine( - $"" + - $"" + - $""); - } - sb.AppendLine("
ModePolicyPolicy URI
{WebUtility.HtmlEncode(profile.SecurityMode)}{WebUtility.HtmlEncode(profile.PolicyName)}{WebUtility.HtmlEncode(profile.PolicyUri)}
"); - - if (data.Endpoints.UserTokenPolicies.Count > 0) - sb.AppendLine( - $"

User Token Policies: {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}

"); - } - sb.AppendLine("
"); - - // Redundancy panel (only when enabled) - if (data.Redundancy != null) - { - var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow"; - sb.AppendLine($"

Redundancy

"); - sb.AppendLine( - $"

Mode: {data.Redundancy.Mode} | Role: {data.Redundancy.Role} | Service Level: {data.Redundancy.ServiceLevel}

"); - sb.AppendLine($"

Application URI: {data.Redundancy.ApplicationUri}

"); - sb.AppendLine($"

Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}

"); - sb.AppendLine("
"); - } - - // Subscriptions panel - sb.AppendLine("

Subscriptions

"); - sb.AppendLine($"

Active: {data.Subscriptions.ActiveCount}

"); - if (data.Subscriptions.ProbeCount > 0) - sb.AppendLine( - $"

Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)

"); - sb.AppendLine("
"); - - // Data Change Dispatch panel - sb.AppendLine("

Data Change Dispatch

"); - sb.AppendLine( - $"

Events/sec: {data.DataChange.EventsPerSecond:F1} | Avg Batch Size: {data.DataChange.AvgBatchSize:F1} | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}

"); - sb.AppendLine("
"); - - // Galaxy Info panel - sb.AppendLine("

Galaxy Info

"); - sb.AppendLine( - $"

Galaxy: {data.Galaxy.GalaxyName} | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}

"); - sb.AppendLine( - $"

Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}

"); - sb.AppendLine($"

Last Rebuild: {data.Galaxy.LastRebuildTime:O}

"); - sb.AppendLine("
"); - - // Galaxy Runtime panel — per-host Platform + AppEngine state - if (data.RuntimeStatus.Total > 0) - { - var rtColor = data.RuntimeStatus.StoppedCount > 0 ? "red" - : data.RuntimeStatus.UnknownCount > 0 ? "yellow" - : "green"; - sb.AppendLine($"

Galaxy Runtime

"); - sb.AppendLine( - $"

{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" + - $" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)

"); - sb.AppendLine(""); - foreach (var host in data.RuntimeStatus.Hosts) - { - var since = host.LastStateChangeTime?.ToString("O") ?? "-"; - var err = WebUtility.HtmlEncode(host.LastError ?? ""); - sb.AppendLine( - $"" + - $"" + - $"" + - $"" + - $""); - } - sb.AppendLine("
NameKindStateSinceLast Error
{WebUtility.HtmlEncode(host.ObjectName)}{WebUtility.HtmlEncode(host.Kind)}{host.State}{since}{err}
"); - sb.AppendLine("
"); - } - - // Historian panel - var anyClusterNodeFailed = - data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount < data.Historian.NodeCount; - var allClusterNodesFailed = - data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount == 0; - var histColor = !data.Historian.Enabled ? "gray" - : data.Historian.PluginStatus != "Loaded" ? "red" - : allClusterNodesFailed ? "red" - : data.Historian.ConsecutiveFailures >= 5 ? "red" - : anyClusterNodeFailed || data.Historian.ConsecutiveFailures > 0 ? "yellow" - : "green"; - sb.AppendLine($"

Historian

"); - sb.AppendLine( - $"

Enabled: {data.Historian.Enabled} | Plugin: {data.Historian.PluginStatus} | Port: {data.Historian.Port}

"); - if (!string.IsNullOrEmpty(data.Historian.PluginError)) - sb.AppendLine($"

Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}

"); - if (data.Historian.PluginStatus == "Loaded") - { - sb.AppendLine( - $"

Queries: {data.Historian.QueryTotal:N0} " + - $"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " + - $"| Consecutive Failures: {data.Historian.ConsecutiveFailures}

"); - var procBadge = data.Historian.ProcessConnectionOpen - ? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveProcessNode ?? "?")})" - : "closed"; - var evtBadge = data.Historian.EventConnectionOpen - ? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveEventNode ?? "?")})" - : "closed"; - sb.AppendLine( - $"

Process Conn: {procBadge} | Event Conn: {evtBadge}

"); - if (data.Historian.LastSuccessTime.HasValue) - sb.AppendLine($"

Last Success: {data.Historian.LastSuccessTime:O}

"); - if (data.Historian.LastFailureTime.HasValue) - sb.AppendLine($"

Last Failure: {data.Historian.LastFailureTime:O}

"); - if (!string.IsNullOrEmpty(data.Historian.LastQueryError)) - sb.AppendLine( - $"

Last Error: {WebUtility.HtmlEncode(data.Historian.LastQueryError)}

"); - - // Cluster table: only when a true multi-node cluster is configured. - if (data.Historian.NodeCount > 1) - { - sb.AppendLine( - $"

Cluster: {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy

"); - sb.AppendLine( - ""); - foreach (var node in data.Historian.Nodes) - { - var state = node.IsHealthy ? "healthy" : "cooldown"; - var cooldown = node.CooldownUntil?.ToString("O") ?? "-"; - var lastErr = WebUtility.HtmlEncode(node.LastError ?? ""); - sb.AppendLine( - $"" + - $""); - } - sb.AppendLine("
NodeStateCooldown UntilFailuresLast Error
{WebUtility.HtmlEncode(node.Name)}{state}{cooldown}{node.FailureCount}{lastErr}
"); - } - else if (data.Historian.NodeCount == 1) - { - sb.AppendLine($"

Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}

"); - } - } - sb.AppendLine("
"); - - // Alarms panel - var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray" - : data.Alarms.AckWriteFailures > 0 ? "yellow" : "green"; - sb.AppendLine($"

Alarms

"); - sb.AppendLine( - $"

Tracking: {data.Alarms.TrackingEnabled} | Conditions: {data.Alarms.ConditionCount} | Active: {data.Alarms.ActiveAlarmCount}

"); - sb.AppendLine( - $"

Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}

"); - if (data.Alarms.FilterEnabled) - { - sb.AppendLine( - $"

Filter: {data.Alarms.FilterPatternCount} pattern(s), {data.Alarms.FilterIncludedObjectCount} object(s) included

"); - if (data.Alarms.FilterPatterns.Count > 0) - { - sb.AppendLine("
    "); - foreach (var pattern in data.Alarms.FilterPatterns) - sb.AppendLine($"
  • {WebUtility.HtmlEncode(pattern)}
  • "); - sb.AppendLine("
"); - } - } - else - { - sb.AppendLine("

Filter: disabled (all alarm-bearing objects tracked)

"); - } - sb.AppendLine("
"); - - // Operations table - sb.AppendLine("

Operations

"); - sb.AppendLine( - ""); - foreach (var kvp in data.Operations) - { - var s = kvp.Value; - sb.AppendLine($"" + - $""); - } - - sb.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
{kvp.Key}{s.TotalCount}{s.SuccessRate:P1}{s.AverageMilliseconds:F1}{s.MinMilliseconds:F1}{s.MaxMilliseconds:F1}{s.Percentile95Milliseconds:F1}
"); - - sb.AppendLine(""); - return sb.ToString(); - } - - /// - /// Generates an indented JSON status payload for API consumers. - /// - /// A JSON representation of the current dashboard snapshot. - public string GenerateJson() - { - var data = GetStatusData(); - return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); - } - - /// - /// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint. - /// - /// when the bridge meets the health policy; otherwise, . - public bool IsHealthy() - { - var state = _mxAccessClient?.State ?? ConnectionState.Disconnected; - return _healthCheck.IsHealthy(state, _metrics); - } - - /// - /// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state. - /// - public HealthEndpointData GetHealthData() - { - var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected; - var mxConnected = connectionState == ConnectionState.Connected; - var dbConnected = _galaxyStats?.DbConnected ?? false; - var historianInfo = BuildHistorianStatusInfo(); - var alarmInfo = BuildAlarmStatusInfo(); - var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo); - var uptime = DateTime.UtcNow - _startTime; - - var data = new HealthEndpointData - { - Status = health.Status, - RedundancyEnabled = _redundancyConfig?.Enabled ?? false, - Components = new ComponentHealth - { - MxAccess = connectionState.ToString(), - Database = dbConnected ? "Connected" : "Disconnected", - OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped", - Historian = historianInfo.PluginStatus, - Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled" - }, - Uptime = FormatUptime(uptime), - Timestamp = DateTime.UtcNow - }; - - if (_redundancyConfig != null && _redundancyConfig.Enabled) - { - var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase); - var baseLevel = isPrimary - ? _redundancyConfig.ServiceLevelBase - : Math.Max(0, _redundancyConfig.ServiceLevelBase - 50); - var calculator = new ServiceLevelCalculator(); - - data.ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected); - data.RedundancyRole = _redundancyConfig.Role; - data.RedundancyMode = _redundancyConfig.Mode; - } - else - { - // Non-redundant: 255 when healthy, 0 when both down - data.ServiceLevel = mxConnected ? (byte)255 : (byte)0; - } - - return data; - } - - /// - /// Generates the JSON payload for the /api/health endpoint. - /// - public string GenerateHealthJson() - { - var data = GetHealthData(); - return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); - } - - /// - /// Generates a focused health status HTML page for operators and monitoring dashboards. - /// - public string GenerateHealthHtml() - { - var data = GetHealthData(); - var sb = new StringBuilder(); - - var statusColor = data.Status == "Healthy" ? "#00cc66" : data.Status == "Degraded" ? "#cccc33" : "#cc3333"; - var mxColor = data.Components.MxAccess == "Connected" ? "#00cc66" : "#cc3333"; - var dbColor = data.Components.Database == "Connected" ? "#00cc66" : "#cc3333"; - var uaColor = data.Components.OpcUaServer == "Running" ? "#00cc66" : "#cc3333"; - - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine($""); - sb.AppendLine("LmxOpcUa Health"); - sb.AppendLine(""); - - // Status badge - sb.AppendLine("
"); - sb.AppendLine( - $"
{data.Status.ToUpperInvariant()}
"); - sb.AppendLine("
"); - - // Service Level - sb.AppendLine($"
"); - sb.AppendLine("SERVICE LEVEL"); - sb.AppendLine($"{data.ServiceLevel}"); - sb.AppendLine("
"); - - // Redundancy info - if (data.RedundancyEnabled) - sb.AppendLine( - $"
Role: {data.RedundancyRole} | Mode: {data.RedundancyMode}
"); - - var historianColor = data.Components.Historian == "Loaded" ? "#00cc66" - : data.Components.Historian == "Disabled" ? "#666" : "#cc3333"; - var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666"; - - // Component health cards - sb.AppendLine("
"); - sb.AppendLine( - $"
MXAccess
{data.Components.MxAccess}
"); - sb.AppendLine( - $"
Galaxy Database
{data.Components.Database}
"); - sb.AppendLine( - $"
OPC UA Server
{data.Components.OpcUaServer}
"); - sb.AppendLine( - $"
Historian
{data.Components.Historian}
"); - sb.AppendLine( - $"
Alarm Tracking
{data.Components.Alarms}
"); - sb.AppendLine("
"); - - // Footer - sb.AppendLine($"
Uptime: {data.Uptime} | {data.Timestamp:O}
"); - - sb.AppendLine(""); - return sb.ToString(); - } - - private static string FormatUptime(TimeSpan ts) - { - if (ts.TotalDays >= 1) - return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m"; - if (ts.TotalHours >= 1) - return $"{(int)ts.TotalHours}h {ts.Minutes}m"; - return $"{(int)ts.TotalMinutes}m {ts.Seconds}s"; - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusWebServer.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusWebServer.cs deleted file mode 100644 index 231e6e0..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Status/StatusWebServer.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Serilog; - -namespace ZB.MOM.WW.OtOpcUa.Host.Status -{ - /// - /// HTTP server for status dashboard. Routes: / → HTML, /api/status → JSON, /api/health → 200/503. (DASH-001) - /// - public class StatusWebServer : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - private readonly int _port; - - private readonly StatusReportService _reportService; - private CancellationTokenSource? _cts; - private HttpListener? _listener; - private Task? _listenTask; - - /// - /// Initializes a new dashboard web server bound to the supplied report service and HTTP port. - /// - /// The report service used to generate dashboard responses. - /// The HTTP port to listen on. - public StatusWebServer(StatusReportService reportService, int port) - { - _reportService = reportService; - _port = port; - } - - /// - /// Gets a value indicating whether the dashboard listener is currently accepting requests. - /// - public bool IsRunning => _listener?.IsListening ?? false; - - /// - /// Stops the dashboard listener and releases its resources. - /// - public void Dispose() - { - Stop(); - } - - /// - /// Starts the HTTP listener and background request loop for the status dashboard. - /// - public bool Start() - { - try - { - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://localhost:{_port}/"); - _listener.Start(); - - _cts = new CancellationTokenSource(); - _listenTask = Task.Run(() => ListenLoopAsync(_cts.Token)); - - Log.Information("Status dashboard started on http://localhost:{Port}/", _port); - return true; - } - catch (Exception ex) - { - Log.Error(ex, "Failed to start status dashboard on port {Port}", _port); - _listener = null; - return false; - } - } - - /// - /// Stops the dashboard listener and releases its HTTP resources. - /// - public void Stop() - { - _cts?.Cancel(); - try - { - _listener?.Stop(); - _listener?.Close(); - } - catch - { - /* ignore */ - } - - _listener = null; - try { _listenTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ } - _listenTask = null; - Log.Information("Status dashboard stopped"); - } - - private async Task ListenLoopAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening) - try - { - var context = await _listener.GetContextAsync(); - _ = HandleRequestAsync(context); - } - catch (ObjectDisposedException) - { - break; - } - catch (HttpListenerException) - { - break; - } - catch (Exception ex) - { - Log.Warning(ex, "Dashboard listener error"); - } - } - - private async Task HandleRequestAsync(HttpListenerContext context) - { - try - { - var request = context.Request; - var response = context.Response; - - // Only allow GET - if (request.HttpMethod != "GET") - { - response.StatusCode = 405; - response.Close(); - return; - } - - // No-cache headers - response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); - response.Headers.Add("Pragma", "no-cache"); - response.Headers.Add("Expires", "0"); - - var path = request.Url?.AbsolutePath ?? "/"; - - switch (path) - { - case "/": - await WriteResponse(response, _reportService.GenerateHtml(), "text/html", 200); - break; - - case "/health": - await WriteResponse(response, _reportService.GenerateHealthHtml(), "text/html", 200); - break; - - case "/api/status": - await WriteResponse(response, _reportService.GenerateJson(), "application/json", 200); - break; - - case "/api/health": - var healthData = _reportService.GetHealthData(); - var healthJson = _reportService.GenerateHealthJson(); - var healthStatusCode = healthData.Status == "Unhealthy" ? 503 : 200; - await WriteResponse(response, healthJson, "application/json", healthStatusCode); - break; - - default: - response.StatusCode = 404; - response.Close(); - break; - } - } - catch (Exception ex) - { - Log.Warning(ex, "Error handling dashboard request"); - try - { - context.Response.Close(); - } - catch - { - } - } - } - - private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType, - int statusCode) - { - var buffer = Encoding.UTF8.GetBytes(body); - response.StatusCode = statusCode; - response.ContentType = contentType; - response.ContentLength64 = buffer.Length; - await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); - response.Close(); - } - } -} \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/Utilities/SyncOverAsync.cs b/src/ZB.MOM.WW.OtOpcUa.Host/Utilities/SyncOverAsync.cs deleted file mode 100644 index 2c8a073..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/Utilities/SyncOverAsync.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.OtOpcUa.Host.Utilities -{ - /// - /// Bounded safety wrappers for blocking on async tasks from synchronous OPC UA stack - /// callbacks (Read, Write, HistoryRead*, BuildAddressSpace). These are backstops: the - /// underlying MxAccess / Historian clients already enforce inner timeouts on the async - /// path, but an outer bound is still required so the stack thread cannot be parked - /// indefinitely by a hung scheduler, a slow reconnect, or any other non-returning - /// async path. - /// - /// - /// On timeout, the underlying task is NOT cancelled — it runs to completion on the - /// thread pool and is abandoned. Callers must be comfortable with the fire-forget - /// semantics of the background continuation. This is acceptable for the current call - /// sites because MxAccess and Historian clients are shared singletons whose background - /// work does not capture request-scoped state. - /// - internal static class SyncOverAsync - { - public static void WaitSync(Task task, TimeSpan timeout, string operation) - { - if (task == null) throw new ArgumentNullException(nameof(task)); - try - { - if (!task.Wait(timeout)) - throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s"); - } - catch (AggregateException ae) when (ae.InnerExceptions.Count == 1) - { - // Unwrap the single inner exception so callers can write natural catch blocks. - throw ae.InnerExceptions[0]; - } - } - - public static T WaitSync(Task task, TimeSpan timeout, string operation) - { - if (task == null) throw new ArgumentNullException(nameof(task)); - try - { - if (!task.Wait(timeout)) - throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s"); - return task.Result; - } - catch (AggregateException ae) when (ae.InnerExceptions.Count == 1) - { - throw ae.InnerExceptions[0]; - } - } - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj deleted file mode 100644 index 1ccb2c4..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ /dev/null @@ -1,71 +0,0 @@ - - - - Exe - net48 - x86 - 9.0 - enable - ZB.MOM.WW.OtOpcUa.Host - ZB.MOM.WW.OtOpcUa.Host - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MxAccess.dll - false - - - - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json deleted file mode 100644 index fc0beaf..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "OpcUa": { - "BindAddress": "0.0.0.0", - "Port": 4840, - "EndpointPath": "/LmxOpcUa", - "ServerName": "LmxOpcUa", - "GalaxyName": "ZB", - "MaxSessions": 100, - "SessionTimeoutMinutes": 30, - "AlarmTrackingEnabled": false, - "AlarmFilter": { - "ObjectFilters": [] - }, - "ApplicationUri": null - }, - "MxAccess": { - "ClientName": "LmxOpcUa", - "NodeName": null, - "GalaxyName": null, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10, - "MonitorIntervalSeconds": 5, - "AutoReconnect": true, - "ProbeTag": null, - "ProbeStaleThresholdSeconds": 60, - "RuntimeStatusProbesEnabled": true, - "RuntimeStatusUnknownTimeoutSeconds": 15 - }, - "GalaxyRepository": { - "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;", - "ChangeDetectionIntervalSeconds": 30, - "CommandTimeoutSeconds": 30, - "ExtendedAttributes": false, - "Scope": "Galaxy", - "PlatformName": null - }, - "Dashboard": { - "Enabled": true, - "Port": 8081, - "RefreshIntervalSeconds": 10 - }, - "Authentication": { - "AllowAnonymous": true, - "AnonymousCanWrite": false, - "Ldap": { - "Enabled": false, - "Host": "localhost", - "Port": 3893, - "BaseDN": "dc=lmxopcua,dc=local", - "BindDnTemplate": "cn={username},dc=lmxopcua,dc=local", - "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local", - "ServiceAccountPassword": "serviceaccount123", - "TimeoutSeconds": 5, - "ReadOnlyGroup": "ReadOnly", - "WriteOperateGroup": "WriteOperate", - "WriteTuneGroup": "WriteTune", - "WriteConfigureGroup": "WriteConfigure", - "AlarmAckGroup": "AlarmAck" - } - }, - "Security": { - "Profiles": [ - "None" - ], - "AutoAcceptClientCertificates": true, - "RejectSHA1Certificates": true, - "MinimumCertificateKeySize": 2048, - "PkiRootPath": null, - "CertificateSubject": null - }, - "Redundancy": { - "Enabled": false, - "Mode": "Warm", - "Role": "Primary", - "ServerUris": [], - "ServiceLevelBase": 200 - }, - "Historian": { - "Enabled": false, - "ServerName": "localhost", - "ServerNames": [], - "FailureCooldownSeconds": 60, - "IntegratedSecurity": true, - "UserName": null, - "Password": null, - "Port": 32568, - "CommandTimeoutSeconds": 30, - "MaxValuesPerRead": 10000 - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/FakeHistorianConnectionFactory.cs b/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/FakeHistorianConnectionFactory.cs deleted file mode 100644 index 863d954..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/FakeHistorianConnectionFactory.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Collections.Generic; -using ArchestrA; -using ZB.MOM.WW.OtOpcUa.Historian.Aveva; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests -{ - /// - /// Fake Historian connection factory for tests. Controls whether connections - /// succeed, fail, or timeout without requiring the real Historian SDK runtime. - /// - internal sealed class FakeHistorianConnectionFactory : IHistorianConnectionFactory - { - /// - /// Exception thrown on every CreateAndConnect call unless a more specific rule in - /// or fires first. - /// - public Exception? ConnectException { get; set; } - - public int ConnectCallCount { get; private set; } - - public Action? OnConnect { get; set; } - - /// - /// Per-server-name override: if the requested config.ServerName has an entry - /// whose value is non-null, that exception is thrown instead of the global - /// . Lets tests script cluster failover behavior like - /// "node A always fails; node B always succeeds". - /// - public Dictionary ServerBehaviors { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Ordered history of server names passed to CreateAndConnect so tests can assert the - /// picker's iteration order and failover sequence. - /// - public List ConnectHistory { get; } = new List(); - - public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type) - { - ConnectCallCount++; - ConnectHistory.Add(config.ServerName); - - if (ServerBehaviors.TryGetValue(config.ServerName, out var serverException) && serverException != null) - throw serverException; - - if (OnConnect != null) - { - OnConnect(ConnectCallCount); - } - else if (ConnectException != null) - { - throw ConnectException; - } - - // Return a HistorianAccess that is not actually connected. - // ReadRawAsync etc. will fail when they try to use it, which exercises - // the HandleConnectionError → reconnect path. - return new HistorianAccess(); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterEndpointPickerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterEndpointPickerTests.cs deleted file mode 100644 index 8304916..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterEndpointPickerTests.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests -{ - /// - /// Exhaustive coverage of the cluster endpoint picker: config parsing, healthy-list ordering, - /// cooldown behavior with an injected clock, and thread-safety under concurrent writers. - /// - public class HistorianClusterEndpointPickerTests - { - // ---------- Construction / config parsing ---------- - - [Fact] - public void SingleServerName_FallbackWhenServerNamesEmpty() - { - var picker = new HistorianClusterEndpointPicker(Config(serverName: "host-a")); - picker.NodeCount.ShouldBe(1); - picker.GetHealthyNodes().ShouldBe(new[] { "host-a" }); - } - - [Fact] - public void ServerNames_TakesPrecedenceOverLegacyServerName() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverName: "legacy", serverNames: new[] { "host-a", "host-b" })); - picker.NodeCount.ShouldBe(2); - picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" }); - } - - [Fact] - public void ServerNames_OrderedAsConfigured() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "c", "a", "b" })); - picker.GetHealthyNodes().ShouldBe(new[] { "c", "a", "b" }); - } - - [Fact] - public void ServerNames_WhitespaceTrimmedAndEmptyDropped() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { " host-a ", "", " ", "host-b" })); - picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" }); - } - - [Fact] - public void ServerNames_CaseInsensitiveDeduplication() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "Host-A", "HOST-A", "host-a" })); - picker.NodeCount.ShouldBe(1); - } - - [Fact] - public void EmptyConfig_ProducesEmptyPool() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverName: "", serverNames: Array.Empty())); - picker.NodeCount.ShouldBe(0); - picker.GetHealthyNodes().ShouldBeEmpty(); - } - - // ---------- MarkFailed / cooldown window ---------- - - [Fact] - public void MarkFailed_RemovesNodeFromHealthyList() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now); - - picker.MarkFailed("a", "boom"); - - picker.GetHealthyNodes().ShouldBe(new[] { "b" }); - picker.HealthyNodeCount.ShouldBe(1); - } - - [Fact] - public void MarkFailed_RecordsErrorAndTimestamp() - { - var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) }; - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }), clock.Now); - - picker.MarkFailed("a", "connection refused"); - - var states = picker.SnapshotNodeStates(); - var a = states.First(s => s.Name == "a"); - a.IsHealthy.ShouldBeFalse(); - a.FailureCount.ShouldBe(1); - a.LastError.ShouldBe("connection refused"); - a.LastFailureTime.ShouldBe(clock.UtcNow); - } - - [Fact] - public void MarkFailed_CooldownExpiryRestoresNode() - { - var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) }; - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now); - - picker.MarkFailed("a", "boom"); - picker.GetHealthyNodes().ShouldBe(new[] { "b" }); - - // Advance clock just before expiry — still in cooldown - clock.UtcNow = clock.UtcNow.AddSeconds(59); - picker.GetHealthyNodes().ShouldBe(new[] { "b" }); - - // Advance past cooldown — node returns to pool - clock.UtcNow = clock.UtcNow.AddSeconds(2); - picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" }); - } - - [Fact] - public void ZeroCooldown_NeverBenchesNode() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 0), clock.Now); - - picker.MarkFailed("a", "boom"); - - // Zero cooldown → node remains eligible immediately - picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" }); - var state = picker.SnapshotNodeStates().First(s => s.Name == "a"); - state.FailureCount.ShouldBe(1); - state.LastError.ShouldBe("boom"); - } - - [Fact] - public void AllNodesFailed_HealthyListIsEmpty() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now); - - picker.MarkFailed("a", "boom"); - picker.MarkFailed("b", "boom"); - - picker.GetHealthyNodes().ShouldBeEmpty(); - picker.HealthyNodeCount.ShouldBe(0); - } - - [Fact] - public void MarkFailed_AccumulatesFailureCount() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a" }, cooldownSeconds: 10), clock.Now); - - picker.MarkFailed("a", "error 1"); - clock.UtcNow = clock.UtcNow.AddSeconds(20); // recover - picker.MarkFailed("a", "error 2"); - - picker.SnapshotNodeStates().First().FailureCount.ShouldBe(2); - picker.SnapshotNodeStates().First().LastError.ShouldBe("error 2"); - } - - // ---------- MarkHealthy ---------- - - [Fact] - public void MarkHealthy_ClearsCooldownImmediately() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 3600), clock.Now); - - picker.MarkFailed("a", "boom"); - picker.GetHealthyNodes().ShouldBe(new[] { "b" }); - - picker.MarkHealthy("a"); - picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" }); - } - - [Fact] - public void MarkHealthy_PreservesCumulativeFailureCount() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a" }), clock.Now); - - picker.MarkFailed("a", "boom"); - picker.MarkHealthy("a"); - - var state = picker.SnapshotNodeStates().First(); - state.IsHealthy.ShouldBeTrue(); - state.FailureCount.ShouldBe(1); // history preserved - } - - // ---------- Unknown node handling ---------- - - [Fact] - public void MarkFailed_UnknownNode_IsIgnored() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a" }), clock.Now); - - Should.NotThrow(() => picker.MarkFailed("not-configured", "boom")); - picker.GetHealthyNodes().ShouldBe(new[] { "a" }); - } - - [Fact] - public void MarkHealthy_UnknownNode_IsIgnored() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a" })); - Should.NotThrow(() => picker.MarkHealthy("not-configured")); - } - - // ---------- SnapshotNodeStates ---------- - - [Fact] - public void SnapshotNodeStates_ReflectsConfigurationOrder() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "z", "m", "a" })); - picker.SnapshotNodeStates().Select(s => s.Name).ShouldBe(new[] { "z", "m", "a" }); - } - - [Fact] - public void SnapshotNodeStates_HealthyEntriesHaveNoCooldown() - { - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a" })); - var state = picker.SnapshotNodeStates().First(); - state.IsHealthy.ShouldBeTrue(); - state.CooldownUntil.ShouldBeNull(); - state.LastError.ShouldBeNull(); - state.LastFailureTime.ShouldBeNull(); - } - - // ---------- Thread safety smoke test ---------- - - [Fact] - public void ConcurrentMarkAndQuery_DoesNotCorrupt() - { - var clock = new FakeClock(); - var picker = new HistorianClusterEndpointPicker( - Config(serverNames: new[] { "a", "b", "c", "d" }, cooldownSeconds: 5), clock.Now); - - var tasks = new List(); - for (var i = 0; i < 8; i++) - { - tasks.Add(Task.Run(() => - { - for (var j = 0; j < 1000; j++) - { - picker.MarkFailed("a", "boom"); - picker.MarkHealthy("a"); - _ = picker.GetHealthyNodes(); - _ = picker.SnapshotNodeStates(); - } - })); - } - - Task.WaitAll(tasks.ToArray()); - // Just verify we can still read state after the storm. - picker.NodeCount.ShouldBe(4); - picker.GetHealthyNodes().Count.ShouldBeInRange(3, 4); - } - - // ---------- Helpers ---------- - - private static HistorianConfiguration Config( - string serverName = "localhost", - string[]? serverNames = null, - int cooldownSeconds = 60) - { - return new HistorianConfiguration - { - ServerName = serverName, - ServerNames = (serverNames ?? Array.Empty()).ToList(), - FailureCooldownSeconds = cooldownSeconds - }; - } - - private sealed class FakeClock - { - public DateTime UtcNow { get; set; } = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - public DateTime Now() => UtcNow; - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterFailoverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterFailoverTests.cs deleted file mode 100644 index e4d2d14..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianClusterFailoverTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Linq; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests -{ - /// - /// End-to-end behavior of the cluster endpoint picker wired into - /// . Verifies that a failing node is skipped on the next - /// attempt, that the picker state is shared across process + event silos, and that the - /// health snapshot surfaces the winning node. - /// - public class HistorianClusterFailoverTests - { - private static HistorianConfiguration ClusterConfig(params string[] nodes) => new() - { - Enabled = true, - ServerNames = nodes.ToList(), - Port = 32568, - IntegratedSecurity = true, - CommandTimeoutSeconds = 5, - FailureCooldownSeconds = 60 - }; - - [Fact] - public void Connect_FirstNodeFails_PicksSecond() - { - // host-a fails during connect; host-b connects successfully. The fake returns an - // unconnected HistorianAccess on success, so the query phase will subsequently trip - // HandleConnectionError on host-b — that's expected. The observable signal is that - // the picker tried host-a first, skipped to host-b, and host-a's failure was recorded. - var factory = new FakeHistorianConnectionFactory(); - factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down"); - var config = ClusterConfig("host-a", "host-b"); - using var ds = new HistorianDataSource(config, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" }); - var snap = ds.GetHealthSnapshot(); - snap.NodeCount.ShouldBe(2); - snap.Nodes.Single(n => n.Name == "host-a").IsHealthy.ShouldBeFalse(); - snap.Nodes.Single(n => n.Name == "host-a").FailureCount.ShouldBe(1); - snap.Nodes.Single(n => n.Name == "host-a").LastError.ShouldContain("A down"); - } - - [Fact] - public void Connect_AllNodesFail_ReturnsEmptyResults_AndAllInCooldown() - { - var factory = new FakeHistorianConnectionFactory(); - factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down"); - factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down"); - var config = ClusterConfig("host-a", "host-b"); - using var ds = new HistorianDataSource(config, factory); - - var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - results.Count.ShouldBe(0); - factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" }); - - var snap = ds.GetHealthSnapshot(); - snap.ActiveProcessNode.ShouldBeNull(); - snap.HealthyNodeCount.ShouldBe(0); - snap.TotalFailures.ShouldBe(1); // one read call failed (after all cluster tries) - snap.LastError.ShouldContain("All 2 healthy historian candidate(s) failed"); - snap.LastError.ShouldContain("B down"); // last inner exception preserved - } - - [Fact] - public void Connect_SecondCall_SkipsCooledDownNode() - { - // After first call: host-a is in cooldown (60s), host-b is also marked failed via - // HandleConnectionError since the fake connection doesn't support real queries. - // Second call: both are in cooldown and the picker returns empty → the read method - // catches the "all nodes failed" exception and returns empty without retrying connect. - // We verify this by checking that the second call adds NOTHING to the connect history. - var factory = new FakeHistorianConnectionFactory(); - factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down"); - var config = ClusterConfig("host-a", "host-b"); // 60s cooldown - using var ds = new HistorianDataSource(config, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - factory.ConnectHistory.Clear(); - var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - // Both nodes are in cooldown → picker returns empty → factory is not called at all. - results.Count.ShouldBe(0); - factory.ConnectHistory.ShouldBeEmpty(); - } - - [Fact] - public void Connect_SingleNodeConfig_BehavesLikeLegacy() - { - var factory = new FakeHistorianConnectionFactory(); - var config = new HistorianConfiguration - { - Enabled = true, - ServerName = "legacy-host", - Port = 32568, - FailureCooldownSeconds = 0 - }; - using var ds = new HistorianDataSource(config, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - factory.ConnectHistory.ShouldBe(new[] { "legacy-host" }); - var snap = ds.GetHealthSnapshot(); - snap.NodeCount.ShouldBe(1); - snap.Nodes.Single().Name.ShouldBe("legacy-host"); - } - - [Fact] - public void Connect_PickerOrderRespected() - { - var factory = new FakeHistorianConnectionFactory(); - factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down"); - factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down"); - factory.ServerBehaviors["host-c"] = new InvalidOperationException("C down"); - var config = ClusterConfig("host-a", "host-b", "host-c"); - using var ds = new HistorianDataSource(config, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - // Candidates are tried in configuration order. - factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b", "host-c" }); - } - - [Fact] - public void Connect_SharedPickerAcrossProcessAndEventSilos() - { - // Process path tries host-a, fails, then tries host-b. host-a is in cooldown. When - // the event path subsequently starts with a 0s cooldown, the picker state is shared: - // host-a is still marked failed (via its cooldown window) at the moment the event - // silo asks. The event path therefore must not retry host-a. - var factory = new FakeHistorianConnectionFactory(); - factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down"); - var config = ClusterConfig("host-a", "host-b"); - using var ds = new HistorianDataSource(config, factory); - - // Process path: host-a fails → host-b reached (then torn down mid-query via the fake). - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - // At this point host-a and host-b are both in cooldown. ReadEvents will hit the - // picker's empty-healthy-list path and return empty without calling the factory. - factory.ConnectHistory.Clear(); - var events = ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - events.Count.ShouldBe(0); - factory.ConnectHistory.ShouldBeEmpty(); - // Critical assertion: host-a was NOT retried by the event silo — it's in the - // shared cooldown from the process path's failure. - factory.ConnectHistory.ShouldNotContain("host-a"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianDataSourceLifecycleTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianDataSourceLifecycleTests.cs deleted file mode 100644 index 052bc99..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/HistorianDataSourceLifecycleTests.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Historian.Aveva; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests -{ - /// - /// Verifies Historian data source lifecycle behavior: dispose safety, - /// post-dispose rejection, connection failure handling, and reconnect-after-error. - /// - public class HistorianDataSourceLifecycleTests - { - private static HistorianConfiguration DefaultConfig => new() - { - Enabled = true, - ServerName = "test-historian", - Port = 32568, - IntegratedSecurity = true, - CommandTimeoutSeconds = 5, - // Zero cooldown so reconnect-after-error tests can retry through the cluster picker - // on the very next call, matching the pre-cluster behavior they were written against. - FailureCooldownSeconds = 0 - }; - - [Fact] - public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException() - { - var ds = new HistorianDataSource(DefaultConfig); - ds.Dispose(); - - Should.Throw(() => - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult()); - } - - [Fact] - public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException() - { - var ds = new HistorianDataSource(DefaultConfig); - ds.Dispose(); - - Should.Throw(() => - ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average") - .GetAwaiter().GetResult()); - } - - [Fact] - public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException() - { - var ds = new HistorianDataSource(DefaultConfig); - ds.Dispose(); - - Should.Throw(() => - ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow }) - .GetAwaiter().GetResult()); - } - - [Fact] - public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException() - { - var ds = new HistorianDataSource(DefaultConfig); - ds.Dispose(); - - Should.Throw(() => - ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult()); - } - - [Fact] - public void Dispose_CalledTwice_DoesNotThrow() - { - var ds = new HistorianDataSource(DefaultConfig); - ds.Dispose(); - Should.NotThrow(() => ds.Dispose()); - } - - [Fact] - public void HistorianAggregateMap_UnknownColumn_ReturnsNull() - { - HistorianAggregateMap.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull(); - } - - [Fact] - public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("Connection refused") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - results.Count.ShouldBe(0); - factory.ConnectCallCount.ShouldBe(1); - } - - [Fact] - public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new TimeoutException("Connection timed out") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - results.Count.ShouldBe(0); - } - - [Fact] - public void ReadRawAsync_AfterConnectionError_AttemptsReconnect() - { - var factory = new FakeHistorianConnectionFactory(); - var ds = new HistorianDataSource(DefaultConfig, factory); - - // First call: factory returns a HistorianAccess that isn't actually connected, - // so the query will fail and HandleConnectionError will reset the connection. - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - // Second call: should attempt reconnection via the factory - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - // Factory should have been called twice — once for initial connect, once for reconnect - factory.ConnectCallCount.ShouldBe(2); - } - - [Fact] - public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState() - { - var callCount = 0; - var factory = new FakeHistorianConnectionFactory - { - OnConnect = count => - { - callCount = count; - if (count == 1) - throw new InvalidOperationException("First connection fails"); - // Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing) - } - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - // First read: connection fails - var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - r1.Count.ShouldBe(0); - - // Second read: should attempt new connection without throwing from internal state corruption - var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - callCount.ShouldBe(2); - } - - [Fact] - public void Dispose_DuringConnectionFailure_DoesNotThrow() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("Connection refused") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - // Trigger a failed connection attempt - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - // Dispose should handle the null connection gracefully - Should.NotThrow(() => ds.Dispose()); - } - - // ---------- HistorianHealthSnapshot instrumentation ---------- - - [Fact] - public void GetHealthSnapshot_FreshDataSource_ReportsZeroCounters() - { - var ds = new HistorianDataSource(DefaultConfig, new FakeHistorianConnectionFactory()); - var snap = ds.GetHealthSnapshot(); - - snap.TotalQueries.ShouldBe(0); - snap.TotalSuccesses.ShouldBe(0); - snap.TotalFailures.ShouldBe(0); - snap.ConsecutiveFailures.ShouldBe(0); - snap.LastSuccessTime.ShouldBeNull(); - snap.LastFailureTime.ShouldBeNull(); - snap.LastError.ShouldBeNull(); - snap.ProcessConnectionOpen.ShouldBeFalse(); - snap.EventConnectionOpen.ShouldBeFalse(); - } - - [Fact] - public void GetHealthSnapshot_AfterConnectionFailure_RecordsFailure() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("Connection refused") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - var snap = ds.GetHealthSnapshot(); - snap.TotalQueries.ShouldBe(1); - snap.TotalFailures.ShouldBe(1); - snap.TotalSuccesses.ShouldBe(0); - snap.ConsecutiveFailures.ShouldBe(1); - snap.LastFailureTime.ShouldNotBeNull(); - snap.LastError.ShouldContain("Connection refused"); - snap.ProcessConnectionOpen.ShouldBeFalse(); - } - - [Fact] - public void GetHealthSnapshot_AfterMultipleFailures_IncrementsConsecutive() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("boom") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - for (var i = 0; i < 4; i++) - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) - .GetAwaiter().GetResult(); - - var snap = ds.GetHealthSnapshot(); - snap.TotalFailures.ShouldBe(4); - snap.ConsecutiveFailures.ShouldBe(4); - snap.TotalSuccesses.ShouldBe(0); - } - - [Fact] - public void GetHealthSnapshot_AcrossReadPaths_CountsAllFailures() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("sdk down") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average") - .GetAwaiter().GetResult(); - ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow }) - .GetAwaiter().GetResult(); - ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10) - .GetAwaiter().GetResult(); - - var snap = ds.GetHealthSnapshot(); - snap.TotalFailures.ShouldBe(4); - snap.TotalQueries.ShouldBe(4); - snap.LastError.ShouldContain("sdk down"); - } - - [Fact] - public void GetHealthSnapshot_ErrorMessageCarriesReadPath() - { - var factory = new FakeHistorianConnectionFactory - { - ConnectException = new InvalidOperationException("unreachable") - }; - var ds = new HistorianDataSource(DefaultConfig, factory); - - ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average") - .GetAwaiter().GetResult(); - - var snap = ds.GetHealthSnapshot(); - snap.LastError.ShouldStartWith("aggregate:"); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj deleted file mode 100644 index 13d8ac7..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - net48 - x86 - 9.0 - enable - false - true - ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - ..\..\lib\aahClientManaged.dll - false - - - ..\..\lib\aahClientCommon.dll - false - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs deleted file mode 100644 index 092280d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; - -namespace ZB.MOM.WW.OtOpcUa.IntegrationTests -{ - /// - /// Integration tests that exercise the real Galaxy repository queries against the test database configuration. - /// - public class GalaxyRepositoryServiceTests - { - /// - /// Loads repository configuration from the integration test settings and controls whether extended attributes are - /// enabled. - /// - /// A value indicating whether the extended attribute query path should be enabled. - /// The repository configuration used by the integration test. - private static GalaxyRepositoryConfiguration LoadConfig(bool extendedAttributes = false) - { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.test.json", false) - .Build(); - - var config = new GalaxyRepositoryConfiguration(); - configuration.GetSection("GalaxyRepository").Bind(config); - config.ExtendedAttributes = extendedAttributes; - return config; - } - - /// - /// Confirms that the standard attribute query returns rows from the repository. - /// - [Fact] - public async Task GetAttributesAsync_StandardMode_ReturnsRows() - { - var config = LoadConfig(false); - var service = new GalaxyRepositoryService(config); - - var results = await service.GetAttributesAsync(); - - results.ShouldNotBeEmpty(); - // Standard mode: PrimitiveName and AttributeSource should be empty - results.ShouldAllBe(r => r.PrimitiveName == "" && r.AttributeSource == ""); - } - - /// - /// Confirms that the extended attribute query returns more rows than the standard query path. - /// - [Fact] - public async Task GetAttributesAsync_ExtendedMode_ReturnsMoreRows() - { - var standardConfig = LoadConfig(false); - var extendedConfig = LoadConfig(true); - var standardService = new GalaxyRepositoryService(standardConfig); - var extendedService = new GalaxyRepositoryService(extendedConfig); - - var standardResults = await standardService.GetAttributesAsync(); - var extendedResults = await extendedService.GetAttributesAsync(); - - extendedResults.Count.ShouldBeGreaterThan(standardResults.Count); - } - - /// - /// Confirms that the extended attribute query includes both primitive and dynamic attribute sources. - /// - [Fact] - public async Task GetAttributesAsync_ExtendedMode_IncludesPrimitiveAttributes() - { - var config = LoadConfig(true); - var service = new GalaxyRepositoryService(config); - - var results = await service.GetAttributesAsync(); - - results.ShouldContain(r => r.AttributeSource == "primitive"); - results.ShouldContain(r => r.AttributeSource == "dynamic"); - } - - /// - /// Confirms that extended mode populates attribute-source metadata across the result set. - /// - [Fact] - public async Task GetAttributesAsync_ExtendedMode_PrimitiveNamePopulated() - { - var config = LoadConfig(true); - var service = new GalaxyRepositoryService(config); - - var results = await service.GetAttributesAsync(); - - // Some primitive attributes have non-empty primitive names - // (though many have empty primitive_name for the root UDO) - results.ShouldNotBeEmpty(); - // All should have an attribute source - results.ShouldAllBe(r => r.AttributeSource == "primitive" || r.AttributeSource == "dynamic"); - } - - /// - /// Confirms that standard-mode results always include fully qualified tag references. - /// - [Fact] - public async Task GetAttributesAsync_StandardMode_AllHaveFullTagReference() - { - var config = LoadConfig(false); - var service = new GalaxyRepositoryService(config); - - var results = await service.GetAttributesAsync(); - - results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference)); - results.ShouldAllBe(r => r.FullTagReference.Contains(".")); - } - - /// - /// Confirms that extended-mode results always include fully qualified tag references. - /// - [Fact] - public async Task GetAttributesAsync_ExtendedMode_AllHaveFullTagReference() - { - var config = LoadConfig(true); - var service = new GalaxyRepositoryService(config); - - var results = await service.GetAttributesAsync(); - - results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference)); - results.ShouldAllBe(r => r.FullTagReference.Contains(".")); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj deleted file mode 100644 index b467f22..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - net48 - x86 - 9.0 - enable - false - - false - ZB.MOM.WW.OtOpcUa.IntegrationTests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/appsettings.test.json b/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/appsettings.test.json deleted file mode 100644 index 81a34e7..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/appsettings.test.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "GalaxyRepository": { - "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;" - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/xunit.runner.json b/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/xunit.runner.json deleted file mode 100644 index 08c512b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeTestCollections": false -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Authentication/UserAuthenticationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Authentication/UserAuthenticationTests.cs deleted file mode 100644 index 305fa6f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Authentication/UserAuthenticationTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Authentication -{ - public class UserAuthenticationTests - { - [Fact] - public void AuthenticationConfiguration_Defaults() - { - var config = new AuthenticationConfiguration(); - - config.AllowAnonymous.ShouldBeTrue(); - config.AnonymousCanWrite.ShouldBeTrue(); - } - - [Fact] - public void AuthenticationConfiguration_LdapDefaults() - { - var config = new AuthenticationConfiguration(); - - config.Ldap.ShouldNotBeNull(); - config.Ldap.Enabled.ShouldBeFalse(); - config.Ldap.Host.ShouldBe("localhost"); - config.Ldap.Port.ShouldBe(3893); - config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local"); - config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly"); - config.Ldap.WriteOperateGroup.ShouldBe("WriteOperate"); - config.Ldap.WriteTuneGroup.ShouldBe("WriteTune"); - config.Ldap.WriteConfigureGroup.ShouldBe("WriteConfigure"); - config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck"); - config.Ldap.TimeoutSeconds.ShouldBe(5); - } - - [Fact] - public void LdapConfiguration_BindDnTemplate_Default() - { - var config = new LdapConfiguration(); - config.BindDnTemplate.ShouldBe("cn={username},dc=lmxopcua,dc=local"); - } - - [Fact] - public void LdapAuthenticationProvider_ValidBind_ReturnsTrue() - { - // This test requires GLAuth running on localhost:3893 - // Skip if not available - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue(); - } - catch (Exception) - { - // GLAuth not running - skip gracefully - } - } - - [Fact] - public void LdapAuthenticationProvider_InvalidPassword_ReturnsFalse() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("readonly", "wrongpassword").ShouldBeFalse(); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_UnknownUser_ReturnsFalse() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("nonexistent", "anything").ShouldBeFalse(); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_ReadOnlyUser_HasReadOnlyRole() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue(); - var roles = provider.GetUserRoles("readonly"); - roles.ShouldContain("ReadOnly"); - roles.ShouldNotContain("WriteOperate"); - roles.ShouldNotContain("AlarmAck"); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_WriteOperateUser_HasWriteOperateRole() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("writeop", "writeop123").ShouldBeTrue(); - var roles = provider.GetUserRoles("writeop"); - roles.ShouldContain("WriteOperate"); - roles.ShouldNotContain("AlarmAck"); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_AlarmAckUser_HasAlarmAckRole() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue(); - var roles = provider.GetUserRoles("alarmack"); - roles.ShouldContain("AlarmAck"); - roles.ShouldNotContain("WriteOperate"); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_AdminUser_HasAllRoles() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - try - { - provider.ValidateCredentials("admin", "admin123").ShouldBeTrue(); - var roles = provider.GetUserRoles("admin"); - roles.ShouldContain("ReadOnly"); - roles.ShouldContain("WriteOperate"); - roles.ShouldContain("WriteTune"); - roles.ShouldContain("WriteConfigure"); - roles.ShouldContain("AlarmAck"); - } - catch (Exception) - { - } - } - - [Fact] - public void LdapAuthenticationProvider_ImplementsIRoleProvider() - { - var ldapConfig = CreateGlAuthConfig(); - var provider = new LdapAuthenticationProvider(ldapConfig); - - (provider is IRoleProvider).ShouldBeTrue(); - } - - [Fact] - public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse() - { - var ldapConfig = new LdapConfiguration - { - Enabled = true, - Host = "localhost", - Port = 19999, // no server here - TimeoutSeconds = 1 - }; - var provider = new LdapAuthenticationProvider(ldapConfig); - - provider.ValidateCredentials("anyone", "anything").ShouldBeFalse(); - } - - [Fact] - public void LdapAuthenticationProvider_ConnectionFailure_GetUserRoles_FallsBackToReadOnly() - { - var ldapConfig = new LdapConfiguration - { - Enabled = true, - Host = "localhost", - Port = 19999, // no server here - TimeoutSeconds = 1, - ServiceAccountDn = "cn=svc,dc=test", - ServiceAccountPassword = "test" - }; - var provider = new LdapAuthenticationProvider(ldapConfig); - - var roles = provider.GetUserRoles("anyone"); - roles.ShouldContain("ReadOnly"); - } - - private static LdapConfiguration CreateGlAuthConfig() - { - return new LdapConfiguration - { - Enabled = true, - Host = "localhost", - Port = 3893, - BaseDN = "dc=lmxopcua,dc=local", - BindDnTemplate = "cn={username},dc=lmxopcua,dc=local", - ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local", - ServiceAccountPassword = "serviceaccount123", - TimeoutSeconds = 5, - ReadOnlyGroup = "ReadOnly", - WriteOperateGroup = "WriteOperate", - WriteTuneGroup = "WriteTune", - WriteConfigureGroup = "WriteConfigure", - AlarmAckGroup = "AlarmAck" - }; - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/ConfigurationLoadingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/ConfigurationLoadingTests.cs deleted file mode 100644 index ace2d56..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/ConfigurationLoadingTests.cs +++ /dev/null @@ -1,427 +0,0 @@ -using System.Collections.Generic; -using Microsoft.Extensions.Configuration; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration -{ - /// - /// Verifies that application configuration binds correctly from appsettings and that validation catches invalid bridge - /// settings. - /// - public class ConfigurationLoadingTests - { - /// - /// Loads the application configuration from the repository appsettings file for binding tests. - /// - /// The bound application configuration snapshot. - private static AppConfiguration LoadFromJson() - { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", false) - .Build(); - - var config = new AppConfiguration(); - configuration.GetSection("OpcUa").Bind(config.OpcUa); - configuration.GetSection("MxAccess").Bind(config.MxAccess); - configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository); - configuration.GetSection("Dashboard").Bind(config.Dashboard); - configuration.GetSection("Security").Bind(config.Security); - configuration.GetSection("Historian").Bind(config.Historian); - return config; - } - - /// - /// Confirms that the OPC UA section binds the endpoint and session settings expected by the bridge. - /// - [Fact] - public void OpcUa_Section_BindsCorrectly() - { - var config = LoadFromJson(); - config.OpcUa.BindAddress.ShouldBe("0.0.0.0"); - config.OpcUa.Port.ShouldBe(4840); - config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa"); - config.OpcUa.ServerName.ShouldBe("LmxOpcUa"); - config.OpcUa.GalaxyName.ShouldBe("ZB"); - config.OpcUa.MaxSessions.ShouldBe(100); - config.OpcUa.SessionTimeoutMinutes.ShouldBe(30); - } - - /// - /// Confirms that the MXAccess section binds runtime timeout and reconnect settings correctly. - /// - [Fact] - public void MxAccess_Section_BindsCorrectly() - { - var config = LoadFromJson(); - config.MxAccess.ClientName.ShouldBe("LmxOpcUa"); - config.MxAccess.ReadTimeoutSeconds.ShouldBe(5); - config.MxAccess.WriteTimeoutSeconds.ShouldBe(5); - config.MxAccess.MaxConcurrentOperations.ShouldBe(10); - config.MxAccess.MonitorIntervalSeconds.ShouldBe(5); - config.MxAccess.AutoReconnect.ShouldBe(true); - config.MxAccess.ProbeStaleThresholdSeconds.ShouldBe(60); - } - - /// - /// Confirms that the Galaxy repository section binds connection and polling settings correctly. - /// - [Fact] - public void GalaxyRepository_Section_BindsCorrectly() - { - var config = LoadFromJson(); - config.GalaxyRepository.ConnectionString.ShouldContain("ZB"); - config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30); - config.GalaxyRepository.CommandTimeoutSeconds.ShouldBe(30); - config.GalaxyRepository.ExtendedAttributes.ShouldBe(false); - } - - /// - /// Confirms that extended-attribute loading defaults to disabled when not configured. - /// - [Fact] - public void GalaxyRepository_ExtendedAttributes_DefaultsFalse() - { - var config = new GalaxyRepositoryConfiguration(); - config.ExtendedAttributes.ShouldBe(false); - } - - /// - /// Confirms that the extended-attribute flag can be enabled through configuration binding. - /// - [Fact] - public void GalaxyRepository_ExtendedAttributes_BindsFromJson() - { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", false) - .AddInMemoryCollection(new[] - { new KeyValuePair("GalaxyRepository:ExtendedAttributes", "true") }) - .Build(); - - var config = new GalaxyRepositoryConfiguration(); - configuration.GetSection("GalaxyRepository").Bind(config); - config.ExtendedAttributes.ShouldBe(true); - } - - /// - /// Confirms that the dashboard section binds operator-dashboard settings correctly. - /// - [Fact] - public void Dashboard_Section_BindsCorrectly() - { - var config = LoadFromJson(); - config.Dashboard.Enabled.ShouldBe(true); - config.Dashboard.Port.ShouldBe(8081); - config.Dashboard.RefreshIntervalSeconds.ShouldBe(10); - } - - /// - /// Confirms that the default configuration objects start with the expected bridge defaults. - /// - [Fact] - public void DefaultValues_AreCorrect() - { - var config = new AppConfiguration(); - config.OpcUa.BindAddress.ShouldBe("0.0.0.0"); - config.OpcUa.Port.ShouldBe(4840); - config.MxAccess.ClientName.ShouldBe("LmxOpcUa"); - config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30); - config.Dashboard.Enabled.ShouldBe(true); - } - - /// - /// Confirms that BindAddress can be overridden to a specific hostname or IP. - /// - [Fact] - public void OpcUa_BindAddress_CanBeOverridden() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new[] - { - new KeyValuePair("OpcUa:BindAddress", "localhost") - }) - .Build(); - - var config = new OpcUaConfiguration(); - configuration.GetSection("OpcUa").Bind(config); - config.BindAddress.ShouldBe("localhost"); - } - - /// - /// Confirms that a valid configuration passes startup validation. - /// - [Fact] - public void Validator_ValidConfig_ReturnsTrue() - { - var config = LoadFromJson(); - ConfigurationValidator.ValidateAndLog(config).ShouldBe(true); - } - - /// - /// Confirms that an invalid OPC UA port is rejected by startup validation. - /// - [Fact] - public void Validator_InvalidPort_ReturnsFalse() - { - var config = new AppConfiguration(); - config.OpcUa.Port = 0; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - /// - /// Confirms that an empty Galaxy name is rejected because the bridge requires a namespace target. - /// - [Fact] - public void Validator_EmptyGalaxyName_ReturnsFalse() - { - var config = new AppConfiguration(); - config.OpcUa.GalaxyName = ""; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - /// - /// Confirms that the Security section binds profile list from appsettings.json. - /// - [Fact] - public void Security_Section_BindsProfilesCorrectly() - { - var config = LoadFromJson(); - config.Security.Profiles.ShouldContain("None"); - config.Security.AutoAcceptClientCertificates.ShouldBe(true); - config.Security.MinimumCertificateKeySize.ShouldBe(2048); - } - - /// - /// Stability review 2026-04-13 Finding 3: MxAccess.RequestTimeoutSeconds must be at - /// least 1. Zero or negative values disable the safety bound and are rejected. - /// - [Fact] - public void Validator_MxAccessRequestTimeoutZero_ReturnsFalse() - { - var config = LoadFromJson(); - config.MxAccess.RequestTimeoutSeconds = 0; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - /// - /// Stability review 2026-04-13 Finding 3: Historian.RequestTimeoutSeconds must be at - /// least 1 when historian is enabled. - /// - [Fact] - public void Validator_HistorianRequestTimeoutZero_ReturnsFalse() - { - var config = LoadFromJson(); - config.Historian.Enabled = true; - config.Historian.ServerName = "localhost"; - config.Historian.RequestTimeoutSeconds = 0; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - /// - /// Confirms the bound AppConfiguration carries non-zero default request timeouts. - /// - [Fact] - public void Validator_DefaultRequestTimeouts_AreSensible() - { - var config = new AppConfiguration(); - config.MxAccess.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1); - config.Historian.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1); - } - - /// - /// Confirms that a minimum key size below 2048 is rejected by the validator. - /// - [Fact] - public void Validator_InvalidMinKeySize_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Security.MinimumCertificateKeySize = 1024; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - /// - /// Confirms that a valid configuration with security defaults passes validation. - /// - [Fact] - public void Validator_DefaultSecurityConfig_ReturnsTrue() - { - var config = LoadFromJson(); - ConfigurationValidator.ValidateAndLog(config).ShouldBe(true); - } - - /// - /// Confirms that custom security profiles can be bound from in-memory configuration. - /// - [Fact] - public void Security_Section_BindsCustomProfiles() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new[] - { - new KeyValuePair("Security:Profiles:0", "None"), - new KeyValuePair("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"), - new KeyValuePair("Security:AutoAcceptClientCertificates", "false"), - new KeyValuePair("Security:MinimumCertificateKeySize", "4096") - }) - .Build(); - - // Clear default list before binding to match production behavior - var config = new AppConfiguration(); - config.Security.Profiles.Clear(); - configuration.GetSection("Security").Bind(config.Security); - - config.Security.Profiles.Count.ShouldBe(2); - config.Security.Profiles.ShouldContain("None"); - config.Security.Profiles.ShouldContain("Basic256Sha256-SignAndEncrypt"); - config.Security.AutoAcceptClientCertificates.ShouldBe(false); - config.Security.MinimumCertificateKeySize.ShouldBe(4096); - } - - [Fact] - public void Redundancy_Section_BindsFromJson() - { - var config = LoadFromJson(); - config.Redundancy.Enabled.ShouldBe(false); - config.Redundancy.Mode.ShouldBe("Warm"); - config.Redundancy.Role.ShouldBe("Primary"); - config.Redundancy.ServiceLevelBase.ShouldBe(200); - } - - [Fact] - public void Redundancy_Section_BindsCustomValues() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new[] - { - new KeyValuePair("Redundancy:Enabled", "true"), - new KeyValuePair("Redundancy:Mode", "Hot"), - new KeyValuePair("Redundancy:Role", "Secondary"), - new KeyValuePair("Redundancy:ServiceLevelBase", "180"), - new KeyValuePair("Redundancy:ServerUris:0", "urn:a"), - new KeyValuePair("Redundancy:ServerUris:1", "urn:b") - }) - .Build(); - - var config = new AppConfiguration(); - configuration.GetSection("Redundancy").Bind(config.Redundancy); - - config.Redundancy.Enabled.ShouldBe(true); - config.Redundancy.Mode.ShouldBe("Hot"); - config.Redundancy.Role.ShouldBe("Secondary"); - config.Redundancy.ServiceLevelBase.ShouldBe(180); - config.Redundancy.ServerUris.Count.ShouldBe(2); - } - - [Fact] - public void Validator_RedundancyEnabled_NoApplicationUri_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Redundancy.Enabled = true; - config.Redundancy.ServerUris.Add("urn:a"); - config.Redundancy.ServerUris.Add("urn:b"); - // OpcUa.ApplicationUri is null - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - [Fact] - public void Validator_InvalidServiceLevelBase_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Redundancy.ServiceLevelBase = 0; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - [Fact] - public void OpcUa_ApplicationUri_DefaultsToNull() - { - var config = new OpcUaConfiguration(); - config.ApplicationUri.ShouldBeNull(); - } - - [Fact] - public void OpcUa_ApplicationUri_BindsFromConfig() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new[] - { - new KeyValuePair("OpcUa:ApplicationUri", "urn:test:app") - }) - .Build(); - - var config = new OpcUaConfiguration(); - configuration.GetSection("OpcUa").Bind(config); - config.ApplicationUri.ShouldBe("urn:test:app"); - } - - [Fact] - public void Historian_Section_BindsFromJson() - { - var config = LoadFromJson(); - config.Historian.Enabled.ShouldBe(false); - config.Historian.ServerName.ShouldBe("localhost"); - config.Historian.IntegratedSecurity.ShouldBe(true); - config.Historian.Port.ShouldBe(32568); - config.Historian.CommandTimeoutSeconds.ShouldBe(30); - config.Historian.MaxValuesPerRead.ShouldBe(10000); - } - - [Fact] - public void Historian_Section_BindsCustomValues() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new[] - { - new KeyValuePair("Historian:Enabled", "true"), - new KeyValuePair("Historian:ServerName", "historian-server"), - new KeyValuePair("Historian:IntegratedSecurity", "false"), - new KeyValuePair("Historian:UserName", "testuser"), - new KeyValuePair("Historian:Password", "testpass"), - new KeyValuePair("Historian:Port", "12345"), - new KeyValuePair("Historian:CommandTimeoutSeconds", "60"), - new KeyValuePair("Historian:MaxValuesPerRead", "5000") - }) - .Build(); - - var config = new HistorianConfiguration(); - configuration.GetSection("Historian").Bind(config); - - config.Enabled.ShouldBe(true); - config.ServerName.ShouldBe("historian-server"); - config.IntegratedSecurity.ShouldBe(false); - config.UserName.ShouldBe("testuser"); - config.Password.ShouldBe("testpass"); - config.Port.ShouldBe(12345); - config.CommandTimeoutSeconds.ShouldBe(60); - config.MaxValuesPerRead.ShouldBe(5000); - } - - [Fact] - public void Validator_HistorianEnabled_EmptyServerName_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Historian.Enabled = true; - config.Historian.ServerName = ""; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - [Fact] - public void Validator_HistorianEnabled_InvalidPort_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Historian.Enabled = true; - config.Historian.Port = 0; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - - [Fact] - public void Validator_HistorianEnabled_NoIntegratedSecurity_EmptyUserName_ReturnsFalse() - { - var config = new AppConfiguration(); - config.Historian.Enabled = true; - config.Historian.IntegratedSecurity = false; - config.Historian.UserName = ""; - ConfigurationValidator.ValidateAndLog(config).ShouldBe(false); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/HistorianConfigurationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/HistorianConfigurationTests.cs deleted file mode 100644 index e6af96a..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Configuration/HistorianConfigurationTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration -{ - public class HistorianConfigurationTests - { - [Fact] - public void DefaultConfig_Disabled() - { - var config = new HistorianConfiguration(); - config.Enabled.ShouldBe(false); - } - - [Fact] - public void DefaultConfig_ServerNameLocalhost() - { - var config = new HistorianConfiguration(); - config.ServerName.ShouldBe("localhost"); - } - - [Fact] - public void DefaultConfig_IntegratedSecurityTrue() - { - var config = new HistorianConfiguration(); - config.IntegratedSecurity.ShouldBe(true); - } - - [Fact] - public void DefaultConfig_UserNameNull() - { - var config = new HistorianConfiguration(); - config.UserName.ShouldBeNull(); - } - - [Fact] - public void DefaultConfig_PasswordNull() - { - var config = new HistorianConfiguration(); - config.Password.ShouldBeNull(); - } - - [Fact] - public void DefaultConfig_Port32568() - { - var config = new HistorianConfiguration(); - config.Port.ShouldBe(32568); - } - - [Fact] - public void DefaultConfig_CommandTimeout30() - { - var config = new HistorianConfiguration(); - config.CommandTimeoutSeconds.ShouldBe(30); - } - - [Fact] - public void DefaultConfig_MaxValuesPerRead10000() - { - var config = new HistorianConfiguration(); - config.MaxValuesPerRead.ShouldBe(10000); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/AlarmObjectFilterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/AlarmObjectFilterTests.cs deleted file mode 100644 index 33dba0d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/AlarmObjectFilterTests.cs +++ /dev/null @@ -1,416 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - /// - /// Exhaustive coverage of the template-based alarm object filter's pattern parsing, - /// chain matching, and hierarchy-subtree propagation logic. - /// - public class AlarmObjectFilterTests - { - // ---------- Pattern parsing & compilation ---------- - - [Fact] - public void EmptyConfig_DisablesFilter() - { - var sut = new AlarmObjectFilter(new AlarmFilterConfiguration()); - sut.Enabled.ShouldBeFalse(); - sut.PatternCount.ShouldBe(0); - sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull(); - } - - [Fact] - public void NullConfig_DisablesFilter() - { - var sut = new AlarmObjectFilter(null); - sut.Enabled.ShouldBeFalse(); - sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull(); - } - - [Fact] - public void WhitespaceEntries_AreSkipped() - { - var sut = new AlarmObjectFilter(Config("", " ", "\t")); - sut.Enabled.ShouldBeFalse(); - sut.PatternCount.ShouldBe(0); - } - - [Fact] - public void CommaSeparatedEntry_SplitsIntoMultiplePatterns() - { - var sut = new AlarmObjectFilter(Config("TestMachine*, Pump_*")); - sut.Enabled.ShouldBeTrue(); - sut.PatternCount.ShouldBe(2); - } - - [Fact] - public void CommaAndListForms_Combine() - { - var sut = new AlarmObjectFilter(Config("A*, B*", "C*")); - sut.PatternCount.ShouldBe(3); - } - - [Fact] - public void WhitespaceAroundCommas_IsTrimmed() - { - var sut = new AlarmObjectFilter(Config(" TestMachine* , Pump_* ")); - sut.PatternCount.ShouldBe(2); - sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "Pump_A" }).ShouldBeTrue(); - } - - [Fact] - public void LiteralPattern_MatchesExactTemplate() - { - var sut = new AlarmObjectFilter(Config("TestMachine")); - sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "TestMachine_001" }).ShouldBeFalse(); - sut.MatchesTemplateChain(new List { "OtherMachine" }).ShouldBeFalse(); - } - - [Fact] - public void StarAlonePattern_MatchesAnyNonEmptyChain() - { - var sut = new AlarmObjectFilter(Config("*")); - sut.MatchesTemplateChain(new List { "Foo" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "Bar", "Baz" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List()).ShouldBeFalse(); - } - - [Fact] - public void PrefixWildcard_MatchesSuffix() - { - var sut = new AlarmObjectFilter(Config("*Machine")); - sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "BigMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "MachineThing" }).ShouldBeFalse(); - } - - [Fact] - public void SuffixWildcard_MatchesPrefix() - { - var sut = new AlarmObjectFilter(Config("Test*")); - sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "TestFoo" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "Machine" }).ShouldBeFalse(); - } - - [Fact] - public void BothWildcards_MatchesContains() - { - var sut = new AlarmObjectFilter(Config("*Machine*")); - sut.MatchesTemplateChain(new List { "TestMachineWidget" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "Machine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "Pump" }).ShouldBeFalse(); - } - - [Fact] - public void MiddleWildcard_MatchesWithInnerAnything() - { - var sut = new AlarmObjectFilter(Config("Test*Machine")); - sut.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "TestCoolMachine" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "TestMachineX" }).ShouldBeFalse(); - } - - [Fact] - public void RegexMetacharacters_AreEscapedLiterally() - { - // The '.' in Pump.v2 is a regex metachar; it must be a literal dot. - var sut = new AlarmObjectFilter(Config("Pump.v2")); - sut.MatchesTemplateChain(new List { "Pump.v2" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "PumpXv2" }).ShouldBeFalse(); - } - - [Fact] - public void Matching_IsCaseInsensitive() - { - var sut = new AlarmObjectFilter(Config("testmachine*")); - sut.MatchesTemplateChain(new List { "TestMachine_001" }).ShouldBeTrue(); - sut.MatchesTemplateChain(new List { "TESTMACHINE_XYZ" }).ShouldBeTrue(); - } - - [Fact] - public void GalaxyDollarPrefix_IsNormalizedAway_OnBothSides() - { - var sut = new AlarmObjectFilter(Config("TestMachine*")); - sut.MatchesTemplateChain(new List { "$TestMachine" }).ShouldBeTrue(); - - var withDollarInPattern = new AlarmObjectFilter(Config("$TestMachine*")); - withDollarInPattern.MatchesTemplateChain(new List { "$TestMachine" }).ShouldBeTrue(); - withDollarInPattern.MatchesTemplateChain(new List { "TestMachine" }).ShouldBeTrue(); - } - - // ---------- Template-chain matching ---------- - - [Fact] - public void ChainMatch_AtAncestorPosition_StillMatches() - { - var sut = new AlarmObjectFilter(Config("TestMachine")); - var chain = new List { "TestCoolMachine", "TestMachine", "$UserDefined" }; - sut.MatchesTemplateChain(chain).ShouldBeTrue(); - } - - [Fact] - public void ChainNoMatch_ReturnsFalse() - { - var sut = new AlarmObjectFilter(Config("TestMachine*")); - var chain = new List { "FooBar", "$UserDefined" }; - sut.MatchesTemplateChain(chain).ShouldBeFalse(); - } - - [Fact] - public void EmptyChain_NeverMatchesNonWildcard() - { - var sut = new AlarmObjectFilter(Config("TestMachine*")); - sut.MatchesTemplateChain(new List()).ShouldBeFalse(); - } - - [Fact] - public void NullChain_NeverMatches() - { - var sut = new AlarmObjectFilter(Config("TestMachine*")); - sut.MatchesTemplateChain(null).ShouldBeFalse(); - } - - [Fact] - public void SystemTemplate_MatchesWhenOperatorOptsIn() - { - var sut = new AlarmObjectFilter(Config("Area*")); - sut.MatchesTemplateChain(new List { "$Area" }).ShouldBeTrue(); - } - - [Fact] - public void DuplicateChainEntries_StillMatch() - { - var sut = new AlarmObjectFilter(Config("TestMachine")); - var chain = new List { "TestMachine", "TestMachine", "$UserDefined" }; - sut.MatchesTemplateChain(chain).ShouldBeTrue(); - } - - // ---------- Hierarchy subtree propagation ---------- - - [Fact] - public void FlatHierarchy_OnlyMatchingIdsIncluded() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), - Obj(2, parent: 0, template: "Pump"), - Obj(3, parent: 0, template: "TestMachine") - }; - var sut = new AlarmObjectFilter(Config("TestMachine*")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldContain(1); - included.ShouldContain(3); - included.ShouldNotContain(2); - included.Count.ShouldBe(2); - } - - [Fact] - public void MatchOnGrandparent_PropagatesToGrandchildren() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), // root matches - Obj(2, parent: 1, template: "UnrelatedThing"), // child — inherited - Obj(3, parent: 2, template: "UnrelatedOtherThing") // grandchild — inherited - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldBe(new[] { 1, 2, 3 }, ignoreOrder: true); - } - - [Fact] - public void GrandchildMatch_DoesNotIncludeAncestors() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "Unrelated"), - Obj(2, parent: 1, template: "Unrelated"), - Obj(3, parent: 2, template: "TestMachine") - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldBe(new[] { 3 }); - } - - [Fact] - public void OverlappingMatches_StillSingleInclude() - { - // Grandparent matches AND grandchild matches independently — grandchild still counted once. - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), - Obj(2, parent: 1, template: "Widget"), - Obj(3, parent: 2, template: "TestMachine") - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.Count.ShouldBe(3); - included.ShouldContain(3); - } - - [Fact] - public void SiblingSubtrees_OnlyMatchedSideIncluded() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), // match — left subtree - Obj(2, parent: 1, template: "Child"), - Obj(10, parent: 0, template: "Pump"), // no match — right subtree - Obj(11, parent: 10, template: "PumpChild") - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldBe(new[] { 1, 2 }, ignoreOrder: true); - } - - // ---------- Defensive / edge cases ---------- - - [Fact] - public void OrphanObject_TreatedAsRoot() - { - // Object 2 claims parent 99 which isn't in the hierarchy — still reached as a root. - var hierarchy = new List - { - Obj(2, parent: 99, template: "TestMachine") - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldContain(2); - } - - [Fact] - public void SyntheticCycle_TerminatesWithoutStackOverflow() - { - // A→B→A cycle defended by the visited set. - var hierarchy = new List - { - Obj(1, parent: 2, template: "TestMachine"), - Obj(2, parent: 1, template: "Widget") - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - // No object has a ParentGobjectId of 0, and each references an id that exists — - // neither qualifies as a root under the orphan rule. Empty result is acceptable; - // the critical assertion is that the call returns without crashing. - var included = sut.ResolveIncludedObjects(hierarchy); - included.ShouldNotBeNull(); - } - - [Fact] - public void NullTemplateChain_TreatedAsEmpty() - { - var hierarchy = new List - { - new() { GobjectId = 1, ParentGobjectId = 0, TemplateChain = null! } - }; - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.ShouldBeEmpty(); - } - - [Fact] - public void EmptyHierarchy_ReturnsEmptySet() - { - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(new List())!; - included.ShouldBeEmpty(); - } - - [Fact] - public void NullHierarchy_ReturnsEmptySet() - { - var sut = new AlarmObjectFilter(Config("TestMachine")); - var included = sut.ResolveIncludedObjects(null)!; - included.ShouldBeEmpty(); - } - - [Fact] - public void MultipleRoots_AllProcessed() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), - Obj(2, parent: 0, template: "TestMachine"), - Obj(3, parent: 0, template: "Pump") - }; - var sut = new AlarmObjectFilter(Config("TestMachine*")); - var included = sut.ResolveIncludedObjects(hierarchy)!; - included.Count.ShouldBe(2); - } - - // ---------- UnmatchedPatterns ---------- - - [Fact] - public void UnmatchedPatterns_ListsPatternsWithZeroHits() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine") - }; - var sut = new AlarmObjectFilter(Config("TestMachine*", "NotThere*")); - sut.ResolveIncludedObjects(hierarchy); - sut.UnmatchedPatterns.ShouldContain("NotThere*"); - sut.UnmatchedPatterns.ShouldNotContain("TestMachine*"); - } - - [Fact] - public void UnmatchedPatterns_EmptyWhenAllMatch() - { - var hierarchy = new List - { - Obj(1, parent: 0, template: "TestMachine"), - Obj(2, parent: 0, template: "Pump") - }; - var sut = new AlarmObjectFilter(Config("TestMachine", "Pump")); - sut.ResolveIncludedObjects(hierarchy); - sut.UnmatchedPatterns.ShouldBeEmpty(); - } - - [Fact] - public void UnmatchedPatterns_EmptyWhenFilterDisabled() - { - var sut = new AlarmObjectFilter(new AlarmFilterConfiguration()); - sut.UnmatchedPatterns.ShouldBeEmpty(); - } - - [Fact] - public void UnmatchedPatterns_ResetBetweenResolutions() - { - var hierarchyA = new List { Obj(1, parent: 0, template: "TestMachine") }; - var hierarchyB = new List { Obj(1, parent: 0, template: "Pump") }; - var sut = new AlarmObjectFilter(Config("TestMachine*")); - - sut.ResolveIncludedObjects(hierarchyA); - sut.UnmatchedPatterns.ShouldBeEmpty(); - - sut.ResolveIncludedObjects(hierarchyB); - sut.UnmatchedPatterns.ShouldContain("TestMachine*"); - } - - // ---------- Helpers ---------- - - private static AlarmFilterConfiguration Config(params string[] filters) => - new() { ObjectFilters = filters.ToList() }; - - private static GalaxyObjectInfo Obj(int id, int parent, string template) => new() - { - GobjectId = id, - ParentGobjectId = parent, - TagName = $"Obj_{id}", - BrowseName = $"Obj_{id}", - TemplateChain = new List { template } - }; - - private static List SingleObject() => new() - { - Obj(1, parent: 0, template: "Anything") - }; - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/GalaxyAttributeInfoTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/GalaxyAttributeInfoTests.cs deleted file mode 100644 index 7307f24..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/GalaxyAttributeInfoTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - /// - /// Verifies default and extended-field behavior for Galaxy attribute metadata objects. - /// - public class GalaxyAttributeInfoTests - { - /// - /// Confirms that a default attribute metadata object starts with empty strings for its text fields. - /// - [Fact] - public void DefaultValues_AreEmpty() - { - var info = new GalaxyAttributeInfo(); - info.PrimitiveName.ShouldBe(""); - info.AttributeSource.ShouldBe(""); - info.TagName.ShouldBe(""); - info.AttributeName.ShouldBe(""); - info.FullTagReference.ShouldBe(""); - info.DataTypeName.ShouldBe(""); - info.SecurityClassification.ShouldBe(1); - info.IsHistorized.ShouldBeFalse(); - info.IsAlarm.ShouldBeFalse(); - } - - /// - /// Confirms that primitive-name and attribute-source fields can be populated for extended metadata rows. - /// - [Fact] - public void ExtendedFields_CanBeSet() - { - var info = new GalaxyAttributeInfo - { - PrimitiveName = "UDO", - AttributeSource = "primitive" - }; - info.PrimitiveName.ShouldBe("UDO"); - info.AttributeSource.ShouldBe("primitive"); - } - - /// - /// Confirms that standard attribute rows leave the extended metadata fields empty. - /// - [Fact] - public void StandardAttributes_HaveEmptyExtendedFields() - { - var info = new GalaxyAttributeInfo - { - GobjectId = 1, - TagName = "TestObj", - AttributeName = "MachineID", - FullTagReference = "TestObj.MachineID", - MxDataType = 5 - }; - info.PrimitiveName.ShouldBe(""); - info.AttributeSource.ShouldBe(""); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxDataTypeMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxDataTypeMapperTests.cs deleted file mode 100644 index 5471b25..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxDataTypeMapperTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - /// - /// Verifies how Galaxy MX data types are mapped into OPC UA and CLR types by the bridge. - /// - public class MxDataTypeMapperTests - { - /// - /// Confirms that known Galaxy MX data types map to the expected OPC UA data type node identifiers. - /// - /// The Galaxy MX data type code. - /// The expected OPC UA data type node identifier. - [Theory] - [InlineData(1, 1u)] // Boolean - [InlineData(2, 6u)] // Integer → Int32 - [InlineData(3, 10u)] // Float - [InlineData(4, 11u)] // Double - [InlineData(5, 12u)] // String - [InlineData(6, 13u)] // DateTime - [InlineData(7, 11u)] // ElapsedTime → Double - [InlineData(8, 12u)] // Reference → String - [InlineData(13, 6u)] // Enumeration → Int32 - [InlineData(14, 12u)] // Custom → String - [InlineData(15, 21u)] // InternationalizedString → LocalizedText - [InlineData(16, 12u)] // Custom → String - public void MapToOpcUaDataType_AllKnownTypes(int mxDataType, uint expectedNodeId) - { - MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(expectedNodeId); - } - - /// - /// Confirms that unknown MX data types default to the OPC UA string data type. - /// - /// The unsupported MX data type code. - [Theory] - [InlineData(0)] - [InlineData(99)] - [InlineData(-1)] - public void MapToOpcUaDataType_UnknownDefaultsToString(int mxDataType) - { - MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(12u); // String - } - - /// - /// Confirms that known MX data types map to the expected CLR runtime types. - /// - /// The Galaxy MX data type code. - /// The expected CLR type used by the bridge. - [Theory] - [InlineData(1, typeof(bool))] - [InlineData(2, typeof(int))] - [InlineData(3, typeof(float))] - [InlineData(4, typeof(double))] - [InlineData(5, typeof(string))] - [InlineData(6, typeof(DateTime))] - [InlineData(7, typeof(double))] - [InlineData(8, typeof(string))] - [InlineData(13, typeof(int))] - [InlineData(15, typeof(string))] - public void MapToClrType_AllKnownTypes(int mxDataType, Type expectedType) - { - MxDataTypeMapper.MapToClrType(mxDataType).ShouldBe(expectedType); - } - - /// - /// Confirms that unknown MX data types default to the CLR string type. - /// - [Fact] - public void MapToClrType_UnknownDefaultsToString() - { - MxDataTypeMapper.MapToClrType(999).ShouldBe(typeof(string)); - } - - /// - /// Confirms that the boolean MX type reports the expected OPC UA type name. - /// - [Fact] - public void GetOpcUaTypeName_Boolean() - { - MxDataTypeMapper.GetOpcUaTypeName(1).ShouldBe("Boolean"); - } - - /// - /// Confirms that unknown MX types report the fallback OPC UA type name of string. - /// - [Fact] - public void GetOpcUaTypeName_Unknown_ReturnsString() - { - MxDataTypeMapper.GetOpcUaTypeName(999).ShouldBe("String"); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxErrorCodesTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxErrorCodesTests.cs deleted file mode 100644 index fb5315d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/MxErrorCodesTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - /// - /// Verifies the operator-facing error messages and quality mappings derived from MXAccess error codes. - /// - public class MxErrorCodesTests - { - /// - /// Confirms that known MXAccess error codes produce readable operator-facing descriptions. - /// - /// The MXAccess error code. - /// A substring expected in the returned description. - [Theory] - [InlineData(1008, "Invalid reference")] - [InlineData(1012, "Wrong data type")] - [InlineData(1013, "Not writable")] - [InlineData(1014, "Request timed out")] - [InlineData(1015, "Communication failure")] - [InlineData(1016, "Not connected")] - public void GetMessage_KnownCodes_ContainsDescription(int code, string expectedSubstring) - { - MxErrorCodes.GetMessage(code).ShouldContain(expectedSubstring); - } - - /// - /// Confirms that unknown MXAccess error codes are reported as unknown while preserving the numeric code. - /// - [Fact] - public void GetMessage_UnknownCode_ReturnsUnknown() - { - MxErrorCodes.GetMessage(9999).ShouldContain("Unknown"); - MxErrorCodes.GetMessage(9999).ShouldContain("9999"); - } - - /// - /// Confirms that known MXAccess error codes map to the expected bridge quality values. - /// - /// The MXAccess error code. - /// The expected bridge quality value. - [Theory] - [InlineData(1008, Quality.BadConfigError)] - [InlineData(1012, Quality.BadConfigError)] - [InlineData(1013, Quality.BadOutOfService)] - [InlineData(1014, Quality.BadCommFailure)] - [InlineData(1015, Quality.BadCommFailure)] - [InlineData(1016, Quality.BadNotConnected)] - public void MapToQuality_KnownCodes(int code, Quality expected) - { - MxErrorCodes.MapToQuality(code).ShouldBe(expected); - } - - /// - /// Confirms that unknown MXAccess error codes map to the generic bad quality bucket. - /// - [Fact] - public void MapToQuality_UnknownCode_ReturnsBad() - { - MxErrorCodes.MapToQuality(9999).ShouldBe(Quality.Bad); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/QualityMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/QualityMapperTests.cs deleted file mode 100644 index 7d53985..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/QualityMapperTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - /// - /// Verifies the mapping between MXAccess quality codes, bridge quality values, and OPC UA status codes. - /// - public class QualityMapperTests - { - /// - /// Confirms that bad-family MXAccess quality values map to the expected bridge quality values. - /// - /// The raw MXAccess quality code. - /// The bridge quality value expected for the code. - [Theory] - [InlineData(0, Quality.Bad)] - [InlineData(4, Quality.BadConfigError)] - [InlineData(20, Quality.BadCommFailure)] - [InlineData(32, Quality.BadWaitingForInitialData)] - public void MapFromMxAccess_BadFamily(int input, Quality expected) - { - QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected); - } - - /// - /// Confirms that uncertain-family MXAccess quality values map to the expected bridge quality values. - /// - /// The raw MXAccess quality code. - /// The bridge quality value expected for the code. - [Theory] - [InlineData(64, Quality.Uncertain)] - [InlineData(68, Quality.UncertainLastUsable)] - [InlineData(88, Quality.UncertainSubNormal)] - public void MapFromMxAccess_UncertainFamily(int input, Quality expected) - { - QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected); - } - - /// - /// Confirms that good-family MXAccess quality values map to the expected bridge quality values. - /// - /// The raw MXAccess quality code. - /// The bridge quality value expected for the code. - [Theory] - [InlineData(192, Quality.Good)] - [InlineData(216, Quality.GoodLocalOverride)] - public void MapFromMxAccess_GoodFamily(int input, Quality expected) - { - QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected); - } - - /// - /// Confirms that unknown bad-family values collapse to the generic bad quality bucket. - /// - [Fact] - public void MapFromMxAccess_UnknownBadValue_ReturnsBad() - { - QualityMapper.MapFromMxAccessQuality(63).ShouldBe(Quality.Bad); - } - - /// - /// Confirms that unknown uncertain-family values collapse to the generic uncertain quality bucket. - /// - [Fact] - public void MapFromMxAccess_UnknownUncertainValue_ReturnsUncertain() - { - QualityMapper.MapFromMxAccessQuality(100).ShouldBe(Quality.Uncertain); - } - - /// - /// Confirms that unknown good-family values collapse to the generic good quality bucket. - /// - [Fact] - public void MapFromMxAccess_UnknownGoodValue_ReturnsGood() - { - QualityMapper.MapFromMxAccessQuality(200).ShouldBe(Quality.Good); - } - - /// - /// Confirms that the generic good quality maps to the OPC UA good status code. - /// - [Fact] - public void MapToOpcUa_Good_Returns0() - { - QualityMapper.MapToOpcUaStatusCode(Quality.Good).ShouldBe(0x00000000u); - } - - /// - /// Confirms that the generic bad quality maps to the OPC UA bad status code. - /// - [Fact] - public void MapToOpcUa_Bad_Returns80000000() - { - QualityMapper.MapToOpcUaStatusCode(Quality.Bad).ShouldBe(0x80000000u); - } - - /// - /// Confirms that communication failures map to the OPC UA bad communication-failure status code. - /// - [Fact] - public void MapToOpcUa_BadCommFailure() - { - QualityMapper.MapToOpcUaStatusCode(Quality.BadCommFailure).ShouldBe(0x80050000u); - } - - /// - /// Confirms that the generic uncertain quality maps to the OPC UA uncertain status code. - /// - [Fact] - public void MapToOpcUa_Uncertain() - { - QualityMapper.MapToOpcUaStatusCode(Quality.Uncertain).ShouldBe(0x40000000u); - } - - /// - /// Confirms that good quality values are classified correctly by the quality extension helpers. - /// - [Fact] - public void QualityExtensions_IsGood() - { - Quality.Good.IsGood().ShouldBe(true); - Quality.Good.IsBad().ShouldBe(false); - Quality.Good.IsUncertain().ShouldBe(false); - } - - /// - /// Confirms that bad quality values are classified correctly by the quality extension helpers. - /// - [Fact] - public void QualityExtensions_IsBad() - { - Quality.Bad.IsBad().ShouldBe(true); - Quality.Bad.IsGood().ShouldBe(false); - } - - /// - /// Confirms that uncertain quality values are classified correctly by the quality extension helpers. - /// - [Fact] - public void QualityExtensions_IsUncertain() - { - Quality.Uncertain.IsUncertain().ShouldBe(true); - Quality.Uncertain.IsGood().ShouldBe(false); - Quality.Uncertain.IsBad().ShouldBe(false); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/SecurityClassificationMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/SecurityClassificationMapperTests.cs deleted file mode 100644 index 4056097..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Domain/SecurityClassificationMapperTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Domain -{ - public class SecurityClassificationMapperTests - { - /// - /// Verifies that Galaxy classifications intended for operator and engineering writes remain writable through OPC UA. - /// - /// The Galaxy security classification value being evaluated for write access. - /// The expected writable result for the supplied Galaxy classification. - [Theory] - [InlineData(0, true)] // FreeAccess - [InlineData(1, true)] // Operate - [InlineData(4, true)] // Tune - [InlineData(5, true)] // Configure - public void Writable_SecurityLevels(int classification, bool expected) - { - SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected); - } - - /// - /// Verifies that secured or view-only Galaxy classifications are exposed as read-only attributes. - /// - /// The Galaxy security classification value expected to block writes. - /// The expected writable result for the supplied read-only Galaxy classification. - [Theory] - [InlineData(2, false)] // SecuredWrite - [InlineData(3, false)] // VerifiedWrite - [InlineData(6, false)] // ViewOnly - public void ReadOnly_SecurityLevels(int classification, bool expected) - { - SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected); - } - - /// - /// Verifies that unknown security classifications do not accidentally block writes for unmapped Galaxy values. - /// - /// - /// An unmapped Galaxy security classification value that should fall back to writable - /// behavior. - /// - [Theory] - [InlineData(-1)] - [InlineData(7)] - [InlineData(99)] - public void Unknown_Values_DefaultToWritable(int classification) - { - SecurityClassificationMapper.IsWritable(classification).ShouldBeTrue(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/EndToEnd/FullDataFlowTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/EndToEnd/FullDataFlowTest.cs deleted file mode 100644 index 1167b26..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/EndToEnd/FullDataFlowTest.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Collections.Generic; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.EndToEnd -{ - /// - /// THE ULTIMATE SMOKE TEST: Full service with fakes, verifying the complete data flow. - /// (1) Address space built, (2) MXAccess data change → callback, (3) read → correct tag ref, - /// (4) write → correct tag+value, (5) dashboard has real data. - /// - public class FullDataFlowTest - { - /// - /// Confirms that the fake-backed bridge can start, build the address space, and expose coherent status data end to - /// end. - /// - [Fact] - public void FullDataFlow_EndToEnd() - { - var config = new AppConfiguration - { - OpcUa = new OpcUaConfiguration { Port = 14842, GalaxyName = "TestGalaxy", EndpointPath = "/LmxOpcUa" }, - MxAccess = new MxAccessConfiguration - { ClientName = "Test", ReadTimeoutSeconds = 2, WriteTimeoutSeconds = 2 }, - GalaxyRepository = new GalaxyRepositoryConfiguration { ChangeDetectionIntervalSeconds = 60 }, - Dashboard = new DashboardConfiguration { Enabled = false } - }; - - var proxy = new FakeMxProxy(); - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - new() { GobjectId = 1, TagName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true }, - new() - { - GobjectId = 2, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 1, - IsArea = false - }, - new() - { - GobjectId = 3, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", - BrowseName = "DelmiaReceiver", ParentGobjectId = 2, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 2, TagName = "TestMachine_001", AttributeName = "MachineID", - FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", - FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", - FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false - } - } - }; - - var service = new OpcUaService(config, proxy, repo); - service.Start(); - - try - { - // (1) OPC UA server host created - service.ServerHost.ShouldNotBeNull(); - - // (2) MXAccess connected and proxy registered - proxy.IsRegistered.ShouldBe(true); - service.MxClient.ShouldNotBeNull(); - service.MxClient!.State.ShouldBe(ConnectionState.Connected); - - // (3) Address space model can be built from the same data - var model = AddressSpaceBuilder.Build(repo.Hierarchy, repo.Attributes); - model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true); - model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true); - model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true); - model.VariableCount.ShouldBe(3); - model.ObjectCount.ShouldBe(2); // TestMachine + DelmiaReceiver (DEV is area) - - // (4) Tag reference resolves correctly for read/write - var tagRef = model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"]; - tagRef.ShouldBe("DelmiaReceiver_001.DownloadPath"); - - // (5) Galaxy stats have real data - service.GalaxyStatsInstance.ShouldNotBeNull(); - service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy"); - service.GalaxyStatsInstance.DbConnected.ShouldBe(true); - service.GalaxyStatsInstance.ObjectCount.ShouldBe(3); - service.GalaxyStatsInstance.AttributeCount.ShouldBe(3); - - // (5b) Status report has real data - service.StatusReportInstance.ShouldNotBeNull(); - var html = service.StatusReportInstance!.GenerateHtml(); - html.ShouldContain("TestGalaxy"); - html.ShouldContain("Connected"); - - var json = service.StatusReportInstance.GenerateJson(); - json.ShouldContain("TestGalaxy"); - - service.StatusReportInstance.IsHealthy().ShouldBe(true); - - // Verify change detection is wired - service.ChangeDetectionInstance.ShouldNotBeNull(); - - // Verify metrics created - service.Metrics.ShouldNotBeNull(); - } - finally - { - service.Stop(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/ChangeDetectionServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/ChangeDetectionServiceTests.cs deleted file mode 100644 index 5929f1c..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/ChangeDetectionServiceTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.GalaxyRepository -{ - /// - /// Verifies the polling service that detects Galaxy deploy changes and triggers address-space rebuilds. - /// - public class ChangeDetectionServiceTests - { - /// - /// Confirms that the first poll always triggers an initial rebuild notification. - /// - [Fact] - public async Task FirstPoll_AlwaysTriggers() - { - var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) }; - var service = new ChangeDetectionService(repo, 1); - var triggered = false; - service.OnGalaxyChanged += () => triggered = true; - - service.Start(); - await Task.Delay(500); - service.Stop(); - - triggered.ShouldBe(true); - service.Dispose(); - } - - /// - /// Confirms that repeated polls with the same deploy timestamp do not retrigger rebuilds. - /// - [Fact] - public async Task SameTimestamp_DoesNotTriggerAgain() - { - var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) }; - var service = new ChangeDetectionService(repo, 1); - var triggerCount = 0; - service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount); - - service.Start(); - await Task.Delay(2500); // Should have polled at least twice - service.Stop(); - - triggerCount.ShouldBe(1); // Only the first poll - service.Dispose(); - } - - /// - /// Confirms that a changed deploy timestamp triggers another rebuild notification. - /// - [Fact] - public async Task ChangedTimestamp_TriggersAgain() - { - var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) }; - var service = new ChangeDetectionService(repo, 1); - var triggerCount = 0; - service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount); - - service.Start(); - await Task.Delay(500); - - // Change the deploy time - repo.LastDeployTime = new DateTime(2024, 2, 1); - await Task.Delay(1500); - service.Stop(); - - triggerCount.ShouldBeGreaterThanOrEqualTo(2); - service.Dispose(); - } - - /// - /// Confirms that transient polling failures do not crash the service and allow later recovery. - /// - [Fact] - public async Task FailedPoll_DoesNotCrash_RetriesNext() - { - var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) }; - var service = new ChangeDetectionService(repo, 1); - var triggerCount = 0; - service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount); - - service.Start(); - await Task.Delay(500); - - // Make it fail - repo.ShouldThrow = true; - await Task.Delay(1500); - - // Restore and it should recover - repo.ShouldThrow = false; - repo.LastDeployTime = new DateTime(2024, 3, 1); - await Task.Delay(1500); - service.Stop(); - - // Should have triggered at least on first poll and on the changed timestamp - triggerCount.ShouldBeGreaterThanOrEqualTo(1); - service.Dispose(); - } - - /// - /// Confirms that stopping the service before it starts is a harmless no-op. - /// - [Fact] - public void Stop_BeforeStart_DoesNotThrow() - { - var repo = new FakeGalaxyRepository(); - var service = new ChangeDetectionService(repo, 30); - service.Stop(); // Should not throw - service.Dispose(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/PlatformScopeFilterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/PlatformScopeFilterTests.cs deleted file mode 100644 index 6637f3b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/GalaxyRepository/PlatformScopeFilterTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Collections.Generic; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; - -namespace ZB.MOM.WW.OtOpcUa.Tests.GalaxyRepository -{ - public class PlatformScopeFilterTests - { - // Category constants matching the Galaxy schema. - private const int CatPlatform = 1; - private const int CatAppEngine = 3; - private const int CatUserDefined = 10; - private const int CatArea = 13; - - /// - /// Builds a two-platform Galaxy hierarchy for filtering tests. - /// - /// Structure: - /// Area1 (id=1, area, parent=0) - /// PlatformA (id=10, cat=1, hosted_by=0) ← node "NODEA" - /// EngineA (id=20, cat=3, hosted_by=10) - /// Obj1 (id=30, cat=10, hosted_by=20) - /// Obj2 (id=31, cat=10, hosted_by=20) - /// PlatformB (id=11, cat=1, hosted_by=0) ← node "NODEB" - /// EngineB (id=21, cat=3, hosted_by=11) - /// Obj3 (id=32, cat=10, hosted_by=21) - /// Area2 (id=2, area, parent=0) - /// Obj4 (id=33, cat=10, hosted_by=21) ← hosted by EngineB - /// - private static (List hierarchy, List platforms) CreateTwoPlatformGalaxy() - { - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 10, TagName = "PlatformA", ContainedName = "PlatformA", BrowseName = "PlatformA", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, - new() { GobjectId = 20, TagName = "EngineA_001", ContainedName = "EngineA", BrowseName = "EngineA", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 }, - new() { GobjectId = 30, TagName = "Obj1_001", ContainedName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, - new() { GobjectId = 31, TagName = "Obj2_001", ContainedName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, - new() { GobjectId = 11, TagName = "PlatformB", ContainedName = "PlatformB", BrowseName = "PlatformB", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, - new() { GobjectId = 21, TagName = "EngineB_001", ContainedName = "EngineB", BrowseName = "EngineB", ParentGobjectId = 11, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 11 }, - new() { GobjectId = 32, TagName = "Obj3_001", ContainedName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 21, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 }, - new() { GobjectId = 2, TagName = "Area2", ContainedName = "Area2", BrowseName = "Area2", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 33, TagName = "Obj4_001", ContainedName = "Obj4", BrowseName = "Obj4", ParentGobjectId = 2, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 }, - }; - - var platforms = new List - { - new() { GobjectId = 10, NodeName = "NODEA" }, - new() { GobjectId = 11, NodeName = "NODEB" }, - }; - - return (hierarchy, platforms); - } - - [Fact] - public void Filter_ReturnsOnlyObjectsUnderMatchingPlatform() - { - var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); - - var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA"); - - // Should include: Area1, PlatformA, EngineA, Obj1, Obj2 - // Should exclude: PlatformB, EngineB, Obj3, Area2, Obj4 - ids.ShouldContain(1); // Area1 (ancestor of PlatformA) - ids.ShouldContain(10); // PlatformA - ids.ShouldContain(20); // EngineA - ids.ShouldContain(30); // Obj1 - ids.ShouldContain(31); // Obj2 - ids.ShouldNotContain(11); // PlatformB - ids.ShouldNotContain(21); // EngineB - ids.ShouldNotContain(32); // Obj3 - ids.ShouldNotContain(33); // Obj4 - ids.ShouldNotContain(2); // Area2 (no local children) - filtered.Count.ShouldBe(5); - } - - [Fact] - public void Filter_ReturnsObjectsUnderPlatformB() - { - var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); - - var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEB"); - - // Should include: Area1, PlatformB, EngineB, Obj3, Area2, Obj4 - ids.ShouldContain(1); // Area1 (ancestor of PlatformB) - ids.ShouldContain(11); // PlatformB - ids.ShouldContain(21); // EngineB - ids.ShouldContain(32); // Obj3 - ids.ShouldContain(2); // Area2 (has Obj4 hosted by EngineB) - ids.ShouldContain(33); // Obj4 - // Should exclude PlatformA's subtree - ids.ShouldNotContain(10); - ids.ShouldNotContain(20); - ids.ShouldNotContain(30); - ids.ShouldNotContain(31); - filtered.Count.ShouldBe(6); - } - - [Fact] - public void Filter_IsCaseInsensitiveOnNodeName() - { - var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); - - var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "nodea"); - - filtered.Count.ShouldBe(5); - } - - [Fact] - public void Filter_ReturnsEmptyWhenNoMatchingPlatform() - { - var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); - - var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "UNKNOWN"); - - filtered.ShouldBeEmpty(); - ids.ShouldBeEmpty(); - } - - [Fact] - public void Filter_IncludesAncestorAreasForConnectedTree() - { - // An object nested several levels deep should pull in all ancestor areas. - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "TopArea", ContainedName = "TopArea", BrowseName = "TopArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 2, TagName = "SubArea", ContainedName = "SubArea", BrowseName = "SubArea", ParentGobjectId = 1, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 2, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, - new() { GobjectId = 20, TagName = "Eng", ContainedName = "Eng", BrowseName = "Eng", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 }, - new() { GobjectId = 30, TagName = "Obj", ContainedName = "Obj", BrowseName = "Obj", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, - }; - var platforms = new List { new() { GobjectId = 10, NodeName = "LOCAL" } }; - - var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL"); - - ids.ShouldContain(1); // TopArea - ids.ShouldContain(2); // SubArea - ids.ShouldContain(10); // Platform - ids.ShouldContain(20); // Engine - ids.ShouldContain(30); // Object - filtered.Count.ShouldBe(5); - } - - [Fact] - public void Filter_ExcludesAreaWithNoLocalDescendants() - { - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "UsedArea", ContainedName = "UsedArea", BrowseName = "UsedArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 2, TagName = "EmptyArea", ContainedName = "EmptyArea", BrowseName = "EmptyArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, - new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, - }; - var platforms = new List { new() { GobjectId = 10, NodeName = "LOCAL" } }; - - var (_, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL"); - - ids.ShouldContain(1); // UsedArea (ancestor of Plat) - ids.ShouldNotContain(2); // EmptyArea (no local descendants) - } - - [Fact] - public void FilterAttributes_RetainsOnlyMatchingGobjectIds() - { - var gobjectIds = new HashSet { 10, 30 }; - var attributes = new List - { - new() { GobjectId = 10, TagName = "Plat", AttributeName = "Attr1", FullTagReference = "Plat.Attr1" }, - new() { GobjectId = 20, TagName = "Other", AttributeName = "Attr2", FullTagReference = "Other.Attr2" }, - new() { GobjectId = 30, TagName = "Obj", AttributeName = "Attr3", FullTagReference = "Obj.Attr3" }, - }; - - var filtered = PlatformScopeFilter.FilterAttributes(attributes, gobjectIds); - - filtered.Count.ShouldBe(2); - filtered.ShouldAllBe(a => gobjectIds.Contains(a.GobjectId)); - } - - [Fact] - public void Filter_PreservesOriginalOrder() - { - var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); - - var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA"); - - // Verify the order matches the original hierarchy order for included items. - for (int i = 1; i < filtered.Count; i++) - { - var prevIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i - 1].GobjectId); - var currIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i].GobjectId); - prevIndex.ShouldBeLessThan(currIndex); - } - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeAuthenticationProvider.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeAuthenticationProvider.cs deleted file mode 100644 index a05f9ba..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeAuthenticationProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// Deterministic authentication provider for integration tests. - /// Validates credentials against hardcoded username/password pairs - /// and returns configured role sets per user. - /// - internal class FakeAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider - { - private readonly Dictionary _credentials = new(StringComparer.OrdinalIgnoreCase); - - private readonly Dictionary> _roles = new(StringComparer.OrdinalIgnoreCase); - - public IReadOnlyList GetUserRoles(string username) - { - return _roles.TryGetValue(username, out var roles) ? roles : new[] { AppRoles.ReadOnly }; - } - - public bool ValidateCredentials(string username, string password) - { - return _credentials.TryGetValue(username, out var expected) && expected == password; - } - - public FakeAuthenticationProvider AddUser(string username, string password, params string[] roles) - { - _credentials[username] = password; - _roles[username] = roles; - return this; - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeGalaxyRepository.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeGalaxyRepository.cs deleted file mode 100644 index 5d35793..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeGalaxyRepository.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// In-memory Galaxy repository used by tests to control hierarchy rows, attribute rows, and deploy metadata without - /// SQL access. - /// - public class FakeGalaxyRepository : IGalaxyRepository - { - /// - /// Gets or sets the hierarchy rows returned to address-space construction logic. - /// - public List Hierarchy { get; set; } = new(); - - /// - /// Gets or sets the attribute rows returned to address-space construction logic. - /// - public List Attributes { get; set; } = new(); - - /// - /// Gets or sets the deploy timestamp returned to change-detection logic. - /// - public DateTime? LastDeployTime { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets a value indicating whether connection checks should report success. - /// - public bool ConnectionSucceeds { get; set; } = true; - - /// - /// Gets or sets a value indicating whether repository calls should throw to simulate database failures. - /// - public bool ShouldThrow { get; set; } - - /// - /// Occurs when the fake repository simulates a Galaxy deploy change. - /// - public event Action? OnGalaxyChanged; - - /// - /// Returns the configured hierarchy rows or throws to simulate a repository failure. - /// - /// A cancellation token ignored by the in-memory fake. - /// The configured hierarchy rows. - public Task> GetHierarchyAsync(CancellationToken ct = default) - { - if (ShouldThrow) throw new Exception("Simulated DB failure"); - return Task.FromResult(Hierarchy); - } - - /// - /// Returns the configured attribute rows or throws to simulate a repository failure. - /// - /// A cancellation token ignored by the in-memory fake. - /// The configured attribute rows. - public Task> GetAttributesAsync(CancellationToken ct = default) - { - if (ShouldThrow) throw new Exception("Simulated DB failure"); - return Task.FromResult(Attributes); - } - - /// - /// Returns the configured deploy timestamp or throws to simulate a repository failure. - /// - /// A cancellation token ignored by the in-memory fake. - /// The configured deploy timestamp. - public Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - if (ShouldThrow) throw new Exception("Simulated DB failure"); - return Task.FromResult(LastDeployTime); - } - - /// - /// Returns the configured connection result or throws to simulate a repository failure. - /// - /// A cancellation token ignored by the in-memory fake. - /// The configured connection result. - public Task TestConnectionAsync(CancellationToken ct = default) - { - if (ShouldThrow) throw new Exception("Simulated DB failure"); - return Task.FromResult(ConnectionSucceeds); - } - - /// - /// Raises the deploy-change event so tests can trigger rebuild logic. - /// - public void RaiseGalaxyChanged() - { - OnGalaxyChanged?.Invoke(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxAccessClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxAccessClient.cs deleted file mode 100644 index 9a7c75f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxAccessClient.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// In-memory IMxAccessClient used by tests to drive connection, read, write, and subscription scenarios without COM - /// runtime dependencies. - /// - public class FakeMxAccessClient : IMxAccessClient - { - private readonly ConcurrentDictionary> _subscriptions = - new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets the in-memory tag-value table returned by fake reads. - /// - public ConcurrentDictionary TagValues { get; } = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets the values written through the fake client so tests can assert write behavior. - /// - public List<(string Tag, object Value)> WrittenValues { get; } = new(); - - /// - /// Gets or sets the result returned by fake writes to simulate success or failure. - /// - public bool WriteResult { get; set; } = true; - - /// - /// Gets or sets the connection state returned to the system under test. - /// - public ConnectionState State { get; set; } = ConnectionState.Connected; - - /// - /// Gets the number of active subscriptions currently stored by the fake client. - /// - public int ActiveSubscriptionCount => _subscriptions.Count; - - /// - /// Gets or sets the reconnect count exposed to health and dashboard tests. - /// - public int ReconnectCount { get; set; } - - /// - /// When set, returns a faulted task with this exception. - /// - public Exception? SubscribeException { get; set; } - - /// - /// When set, returns a faulted task with this exception. - /// - public Exception? UnsubscribeException { get; set; } - - /// - /// When set, returns a faulted task with this exception. - /// - public Exception? ReadException { get; set; } - - /// - /// When set, returns a faulted task with this exception. - /// - public Exception? WriteException { get; set; } - - /// - /// Occurs when tests explicitly simulate a connection-state transition. - /// - public event EventHandler? ConnectionStateChanged; - - /// - /// Occurs when tests publish a simulated runtime value change. - /// - public event Action? OnTagValueChanged; - - /// - /// Simulates establishing a healthy runtime connection. - /// - /// A cancellation token that is ignored by the in-memory fake. - public Task ConnectAsync(CancellationToken ct = default) - { - State = ConnectionState.Connected; - return Task.CompletedTask; - } - - /// - /// Simulates disconnecting from the runtime. - /// - public Task DisconnectAsync() - { - State = ConnectionState.Disconnected; - return Task.CompletedTask; - } - - /// - /// Stores a subscription callback so later simulated data changes can target it. - /// - /// The Galaxy attribute reference to monitor. - /// The callback that should receive simulated value changes. - public Task SubscribeAsync(string fullTagReference, Action callback) - { - if (SubscribeException != null) - return Task.FromException(SubscribeException); - _subscriptions[fullTagReference] = callback; - return Task.CompletedTask; - } - - /// - /// Removes a stored subscription callback for the specified tag reference. - /// - /// The Galaxy attribute reference to stop monitoring. - public Task UnsubscribeAsync(string fullTagReference) - { - if (UnsubscribeException != null) - return Task.FromException(UnsubscribeException); - _subscriptions.TryRemove(fullTagReference, out _); - return Task.CompletedTask; - } - - /// - /// Returns the current in-memory VTQ for a tag reference or a bad-quality placeholder when none has been seeded. - /// - /// The Galaxy attribute reference to read. - /// A cancellation token that is ignored by the in-memory fake. - /// The seeded VTQ value or a bad not-connected VTQ when the tag was not populated. - public Task ReadAsync(string fullTagReference, CancellationToken ct = default) - { - if (ReadException != null) - return Task.FromException(ReadException); - if (TagValues.TryGetValue(fullTagReference, out var vtq)) - return Task.FromResult(vtq); - return Task.FromResult(Vtq.Bad(Quality.BadNotConnected)); - } - - /// - /// Records a write request, optionally updates the in-memory tag table, and returns the configured write result. - /// - /// The Galaxy attribute reference being written. - /// The value supplied by the code under test. - /// A cancellation token that is ignored by the in-memory fake. - /// A completed task returning the configured write outcome. - public Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) - { - if (WriteException != null) - return Task.FromException(WriteException); - WrittenValues.Add((fullTagReference, value)); - if (WriteResult) - TagValues[fullTagReference] = Vtq.Good(value); - return Task.FromResult(WriteResult); - } - - /// - /// Releases the fake client. No unmanaged resources are held. - /// - public void Dispose() - { - } - - /// - /// Publishes a simulated tag-value change to both the event stream and any stored subscription callback. - /// - /// The Galaxy attribute reference whose value changed. - /// The value, timestamp, and quality payload to publish. - public void SimulateDataChange(string address, Vtq vtq) - { - OnTagValueChanged?.Invoke(address, vtq); - if (_subscriptions.TryGetValue(address, out var callback)) - callback(address, vtq); - } - - /// - /// Raises a simulated connection-state transition for health and reconnect tests. - /// - /// The previous connection state. - /// The new connection state. - public void RaiseConnectionStateChanged(ConnectionState prev, ConnectionState curr) - { - State = curr; - ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(prev, curr)); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxProxy.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxProxy.cs deleted file mode 100644 index 440c7d3..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/FakeMxProxy.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using ArchestrA.MxAccess; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// Fake IMxProxy for testing without the MxAccess COM runtime. - /// Simulates connections, subscriptions, data changes, and writes. - /// - public class FakeMxProxy : IMxProxy - { - private int _connectionHandle; - private int _nextHandle = 1; - - /// - /// Gets the item-handle to tag-reference map built by the test as attributes are registered with the fake runtime. - /// - public ConcurrentDictionary Items { get; } = new(); - - /// - /// Gets the item handles currently marked as advised so tests can assert subscription behavior. - /// - public ConcurrentDictionary AdvisedItems { get; } = new(); - - /// - /// Gets the values written through the fake runtime so write scenarios can assert the final payload. - /// - public List<(string Address, object Value)> WrittenValues { get; } = new(); - - /// - /// Gets a value indicating whether the fake runtime is currently considered registered. - /// - public bool IsRegistered { get; private set; } - - /// - /// Gets the number of times the system under test attempted to register with the fake runtime. - /// - public int RegisterCallCount { get; private set; } - - /// - /// Gets the number of times the system under test attempted to unregister from the fake runtime. - /// - public int UnregisterCallCount { get; private set; } - - /// - /// Gets or sets a value indicating whether registration should fail to exercise connection-error paths. - /// - public bool ShouldFailRegister { get; set; } - - /// - /// Gets or sets a value indicating whether writes should fail to exercise runtime write-error paths. - /// - public bool ShouldFailWrite { get; set; } - - /// - /// Gets or sets a value indicating whether the fake should suppress the write-complete callback for timeout scenarios. - /// - public bool SkipWriteCompleteCallback { get; set; } - - /// - /// Gets or sets the status code returned in the simulated write-complete callback. - /// - public int WriteCompleteStatus { get; set; } = 0; // 0 = success - - /// - /// Occurs when the fake proxy publishes a simulated runtime data-change callback to the system under test. - /// - public event MxDataChangeHandler? OnDataChange; - - /// - /// Occurs when the fake proxy publishes a simulated write-complete callback to the system under test. - /// - public event MxWriteCompleteHandler? OnWriteComplete; - - /// - /// Simulates the MXAccess registration handshake and returns a synthetic connection handle. - /// - /// The client name supplied by the code under test. - /// A synthetic connection handle for subsequent fake operations. - public int Register(string clientName) - { - RegisterCallCount++; - if (ShouldFailRegister) throw new InvalidOperationException("Register failed (simulated)"); - IsRegistered = true; - _connectionHandle = Interlocked.Increment(ref _nextHandle); - return _connectionHandle; - } - - /// - /// Simulates tearing down the fake MXAccess connection. - /// - /// The connection handle supplied by the code under test. - public void Unregister(int handle) - { - UnregisterCallCount++; - IsRegistered = false; - _connectionHandle = 0; - } - - /// - /// Simulates resolving a tag reference into a fake runtime item handle. - /// - /// The synthetic connection handle. - /// The Galaxy attribute reference being registered. - /// A synthetic item handle. - public int AddItem(int handle, string address) - { - var itemHandle = Interlocked.Increment(ref _nextHandle); - Items[itemHandle] = address; - return itemHandle; - } - - /// - /// Simulates removing an item from the fake runtime session. - /// - /// The synthetic connection handle. - /// The synthetic item handle to remove. - public void RemoveItem(int handle, int itemHandle) - { - Items.TryRemove(itemHandle, out _); - } - - /// - /// Marks an item as actively advised so tests can assert subscription activation. - /// - /// The synthetic connection handle. - /// The synthetic item handle being monitored. - public void AdviseSupervisory(int handle, int itemHandle) - { - AdvisedItems[itemHandle] = true; - } - - /// - /// Marks an item as no longer advised so tests can assert subscription teardown. - /// - /// The synthetic connection handle. - /// The synthetic item handle no longer being monitored. - public void UnAdviseSupervisory(int handle, int itemHandle) - { - AdvisedItems.TryRemove(itemHandle, out _); - } - - /// - /// Simulates a runtime write, records the written value, and optionally raises the write-complete callback. - /// - /// The synthetic connection handle. - /// The synthetic item handle to write. - /// The value supplied by the system under test. - /// The security classification supplied with the write request. - public void Write(int handle, int itemHandle, object value, int securityClassification) - { - if (ShouldFailWrite) throw new InvalidOperationException("Write failed (simulated)"); - - if (Items.TryGetValue(itemHandle, out var address)) - WrittenValues.Add((address, value)); - - // Simulate async write complete callback - var status = new MXSTATUS_PROXY[1]; - if (WriteCompleteStatus == 0) - { - status[0].success = 1; - } - else - { - status[0].success = 0; - status[0].detail = (short)WriteCompleteStatus; - } - - if (!SkipWriteCompleteCallback) - OnWriteComplete?.Invoke(_connectionHandle, itemHandle, ref status); - } - - /// - /// Simulates an MXAccess data change event for a specific item handle. - /// - /// The synthetic item handle that should receive the new value. - /// The value to publish to the system under test. - /// The runtime quality code to send with the value. - /// The optional timestamp to send with the value; defaults to the current UTC time. - public void SimulateDataChange(int itemHandle, object value, int quality = 192, DateTime? timestamp = null) - { - var status = new MXSTATUS_PROXY[1]; - status[0].success = 1; - OnDataChange?.Invoke(_connectionHandle, itemHandle, value, quality, - timestamp ?? DateTime.UtcNow, ref status); - } - - /// - /// Simulates data change for a specific address (finds handle by address). - /// - /// The Galaxy attribute reference whose registered handle should receive the new value. - /// The value to publish to the system under test. - /// The runtime quality code to send with the value. - /// The optional timestamp to send with the value; defaults to the current UTC time. - public void SimulateDataChangeByAddress(string address, object value, int quality = 192, - DateTime? timestamp = null) - { - foreach (var kvp in Items) - if (string.Equals(kvp.Value, address, StringComparison.OrdinalIgnoreCase)) - { - SimulateDataChange(kvp.Key, value, quality, timestamp); - return; - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixture.cs deleted file mode 100644 index 1e8c5b0..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixture.cs +++ /dev/null @@ -1,195 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// xUnit fixture that manages an OpcUaService lifecycle with automatic port allocation. - /// Guarantees no port conflicts between parallel tests. - /// Usage (per-test): - /// var fixture = OpcUaServerFixture.WithFakes(); - /// await fixture.InitializeAsync(); - /// try { ... } finally { await fixture.DisposeAsync(); } - /// Usage (skip COM entirely): - /// var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - /// - internal class OpcUaServerFixture : IAsyncLifetime - { - private static int _nextPort = 16000; - - private readonly OpcUaServiceBuilder _builder; - private bool _started; - - /// - /// Initializes a fixture around a prepared service builder and optional fake dependencies. - /// - /// The builder used to construct the service under test. - /// The optional fake Galaxy repository exposed to tests. - /// The optional fake MXAccess client exposed to tests. - /// The optional fake MXAccess proxy exposed to tests. - private OpcUaServerFixture(OpcUaServiceBuilder builder, - FakeGalaxyRepository? repo = null, - FakeMxAccessClient? mxClient = null, - FakeMxProxy? mxProxy = null) - { - OpcUaPort = Interlocked.Increment(ref _nextPort); - _builder = builder; - _builder.WithOpcUaPort(OpcUaPort); - _builder.DisableDashboard(); - GalaxyRepository = repo; - MxAccessClient = mxClient; - MxProxy = mxProxy; - } - - /// - /// Gets the started service instance managed by the fixture. - /// - public OpcUaService Service { get; private set; } = null!; - - /// - /// Gets the OPC UA port assigned to this fixture instance. - /// - public int OpcUaPort { get; } - - /// - /// Gets the OPC UA endpoint URL exposed by the fixture. - /// - public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa"; - - /// - /// The fake Galaxy repository injected into the service. Mutate Hierarchy/Attributes - /// then call Service.TriggerRebuild() to simulate a Galaxy redeployment. - /// - public FakeGalaxyRepository? GalaxyRepository { get; } - - /// - /// The fake MxAccess client injected into the service (when using WithFakeMxAccessClient). - /// - public FakeMxAccessClient? MxAccessClient { get; } - - /// - /// The fake MxProxy injected into the service (when using WithFakes). - /// - public FakeMxProxy? MxProxy { get; } - - /// - /// Builds and starts the OPC UA service for the current fixture. - /// - public Task InitializeAsync() - { - Service = _builder.Build(); - Service.Start(); - _started = true; - return Task.CompletedTask; - } - - /// - /// Stops the OPC UA service when the fixture had previously been started. - /// - public Task DisposeAsync() - { - if (_started) - try - { - Service.Stop(); - } - catch - { - /* swallow cleanup errors */ - } - - return Task.CompletedTask; - } - - /// - /// Creates fixture with FakeMxProxy + FakeGalaxyRepository (standard test data). - /// The STA thread and COM interop run against FakeMxProxy. - /// - /// An optional fake proxy to inject; otherwise a default fake is created. - /// An optional fake repository to inject; otherwise standard test data is used. - /// A fixture configured to exercise the COM-style runtime path. - public static OpcUaServerFixture WithFakes( - FakeMxProxy? proxy = null, - FakeGalaxyRepository? repo = null) - { - var p = proxy ?? new FakeMxProxy(); - var r = repo ?? new FakeGalaxyRepository - { - Hierarchy = TestData.CreateStandardHierarchy(), - Attributes = TestData.CreateStandardAttributes() - }; - - var builder = new OpcUaServiceBuilder() - .WithMxProxy(p) - .WithGalaxyRepository(r) - .WithGalaxyName("TestGalaxy"); - - return new OpcUaServerFixture(builder, r, mxProxy: p); - } - - /// - /// Creates fixture using FakeMxAccessClient directly — skips STA thread + COM entirely. - /// Fastest option for tests that don't need real COM interop. - /// - /// An optional fake MXAccess client to inject; otherwise a default fake is created. - /// An optional fake repository to inject; otherwise standard test data is used. - /// An optional security profile configuration for the test server. - /// An optional redundancy configuration for the test server. - /// An optional explicit application URI for the test server. - /// An optional server name override for the test server. - /// A fixture configured to exercise the direct fake-client path. - public static OpcUaServerFixture WithFakeMxAccessClient( - FakeMxAccessClient? mxClient = null, - FakeGalaxyRepository? repo = null, - SecurityProfileConfiguration? security = null, - RedundancyConfiguration? redundancy = null, - string? applicationUri = null, - string? serverName = null, - AuthenticationConfiguration? authConfig = null, - IUserAuthenticationProvider? authProvider = null, - bool alarmTrackingEnabled = false, - string[]? alarmObjectFilters = null) - { - var client = mxClient ?? new FakeMxAccessClient(); - var r = repo ?? new FakeGalaxyRepository - { - Hierarchy = TestData.CreateStandardHierarchy(), - Attributes = TestData.CreateStandardAttributes() - }; - - var builder = new OpcUaServiceBuilder() - .WithMxAccessClient(client) - .WithGalaxyRepository(r) - .WithGalaxyName("TestGalaxy"); - - if (security != null) - builder.WithSecurity(security); - if (redundancy != null) - builder.WithRedundancy(redundancy); - if (applicationUri != null) - builder.WithApplicationUri(applicationUri); - if (serverName != null) - builder.WithGalaxyName(serverName); - if (authConfig != null) - builder.WithAuthentication(authConfig); - if (authProvider != null) - builder.WithAuthProvider(authProvider); - if (alarmTrackingEnabled) - builder.WithAlarmTracking(true); - if (alarmObjectFilters != null) - builder.WithAlarmFilter(alarmObjectFilters); - - return new OpcUaServerFixture(builder, r, client); - } - - /// - /// Exposes the node manager currently published by the running fixture so tests can assert - /// filter counters, alarm condition counts, and other runtime telemetry. - /// - public ZB.MOM.WW.OtOpcUa.Host.OpcUa.LmxNodeManager? NodeManager => Service.NodeManagerInstance; - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixtureTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixtureTests.cs deleted file mode 100644 index aad07c1..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaServerFixtureTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Diagnostics; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// Verifies the reusable OPC UA server fixture used by integration and wiring tests. - /// - public class OpcUaServerFixtureTests - { - /// - /// Confirms that the standard fake-backed fixture starts the bridge and tears it down cleanly. - /// - [Fact] - public async Task WithFakes_StartsAndStops() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - - fixture.Service.ShouldNotBeNull(); - fixture.Service.MxClient.ShouldNotBeNull(); - fixture.Service.MxClient!.State.ShouldBe(ConnectionState.Connected); - fixture.Service.GalaxyStatsInstance.ShouldNotBeNull(); - fixture.Service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy"); - fixture.OpcUaPort.ShouldBeGreaterThan(16000); - fixture.EndpointUrl.ShouldContain(fixture.OpcUaPort.ToString()); - - await fixture.DisposeAsync(); - } - - /// - /// Confirms that the fake-client fixture bypasses COM wiring and uses the provided fake runtime client. - /// - [Fact] - public async Task WithFakeMxAccessClient_SkipsCom() - { - var mxClient = new FakeMxAccessClient(); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - - fixture.Service.MxClient.ShouldBe(mxClient); - mxClient.State.ShouldBe(ConnectionState.Connected); - - await fixture.DisposeAsync(); - } - - /// - /// Confirms that separate fixture instances automatically allocate unique OPC UA ports. - /// - [Fact] - public async Task MultipleFixtures_GetUniquePortsAutomatically() - { - var fixture1 = OpcUaServerFixture.WithFakeMxAccessClient(); - var fixture2 = OpcUaServerFixture.WithFakeMxAccessClient(); - - fixture1.OpcUaPort.ShouldNotBe(fixture2.OpcUaPort); - - // Both can start without port conflicts - await fixture1.InitializeAsync(); - await fixture2.InitializeAsync(); - - fixture1.Service.ShouldNotBeNull(); - fixture2.Service.ShouldNotBeNull(); - - await fixture1.DisposeAsync(); - await fixture2.DisposeAsync(); - } - - /// - /// Confirms that fixture shutdown completes quickly enough for the integration test suite. - /// - [Fact] - public async Task Shutdown_CompletesWithin30Seconds() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - - var sw = Stopwatch.StartNew(); - await fixture.DisposeAsync(); - sw.Stop(); - - sw.Elapsed.TotalSeconds.ShouldBeLessThan(30); - } - - /// - /// Confirms that runtime callbacks arriving after shutdown are ignored cleanly. - /// - [Fact] - public async Task Stop_UnhooksNodeManagerFromMxAccessCallbacks() - { - var mxClient = new FakeMxAccessClient(); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - - await fixture.DisposeAsync(); - - Should.NotThrow(() => mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good(42))); - } - - /// - /// Confirms that the fake-backed fixture builds the seeded address space and Galaxy statistics. - /// - [Fact] - public async Task WithFakes_BuildsAddressSpace() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - - fixture.Service.GalaxyStatsInstance!.ObjectCount.ShouldBe(5); - fixture.Service.GalaxyStatsInstance.AttributeCount.ShouldBe(6); - fixture.Service.GalaxyStatsInstance.DbConnected.ShouldBe(true); - - await fixture.DisposeAsync(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaTestClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaTestClient.cs deleted file mode 100644 index e90b83c..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/OpcUaTestClient.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using Opc.Ua; -using Opc.Ua.Client; -using Opc.Ua.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// OPC UA client helper for integration tests. Connects to a test server, - /// browses, reads, and subscribes to nodes programmatically. - /// - internal class OpcUaTestClient : IDisposable - { - private Session? _session; - - /// - /// Gets the active OPC UA session used by integration tests once the helper has connected to the bridge. - /// - public Session Session => _session ?? throw new InvalidOperationException("Not connected"); - - /// - /// Closes the test session and releases OPC UA client resources. - /// - public void Dispose() - { - if (_session != null) - { - try - { - _session.Close(); - } - catch - { - /* ignore */ - } - - _session.Dispose(); - } - } - - /// - /// Resolves the namespace index for a given namespace URI (e.g., "urn:TestGalaxy:LmxOpcUa"). - /// - /// The Galaxy name whose OPC UA namespace should be resolved on the test server. - /// The namespace index assigned by the server for the requested Galaxy namespace. - public ushort GetNamespaceIndex(string galaxyName = "TestGalaxy") - { - var nsUri = $"urn:{galaxyName}:LmxOpcUa"; - var idx = Session.NamespaceUris.GetIndex(nsUri); - if (idx < 0) throw new InvalidOperationException($"Namespace '{nsUri}' not found on server"); - return (ushort)idx; - } - - /// - /// Creates a NodeId in the LmxOpcUa namespace using the server's actual namespace index. - /// - /// The string identifier for the node inside the Galaxy namespace. - /// The Galaxy name whose namespace should be used for the node identifier. - /// A node identifier that targets the requested node on the test server. - public NodeId MakeNodeId(string identifier, string galaxyName = "TestGalaxy") - { - return new NodeId(identifier, GetNamespaceIndex(galaxyName)); - } - - /// - /// Connects the helper to an OPC UA endpoint exposed by the test bridge. - /// - /// The OPC UA endpoint URL to connect to. - /// The requested message security mode (default: None). - /// Optional username for authenticated connections. - /// Optional password for authenticated connections. - public async Task ConnectAsync(string endpointUrl, - MessageSecurityMode securityMode = MessageSecurityMode.None, - string? username = null, string? password = null) - { - var config = new ApplicationConfiguration - { - ApplicationName = "OpcUaTestClient", - ApplicationUri = "urn:localhost:OpcUaTestClient", - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - ApplicationCertificate = new CertificateIdentifier - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "own") - }, - TrustedIssuerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "issuer") - }, - TrustedPeerCertificates = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "trusted") - }, - RejectedCertificateStore = new CertificateTrustList - { - StoreType = CertificateStoreType.Directory, - StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "rejected") - }, - AutoAcceptUntrustedCertificates = true - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 30000 }, - TransportQuotas = new TransportQuotas() - }; - - await config.Validate(ApplicationType.Client); - config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - - EndpointDescription endpoint; - if (securityMode != MessageSecurityMode.None) - { - // Ensure client certificate exists for secure connections - var app = new ApplicationInstance - { - ApplicationName = "OpcUaTestClient", - ApplicationType = ApplicationType.Client, - ApplicationConfiguration = config - }; - await app.CheckApplicationInstanceCertificate(false, 2048); - - // Discover and select endpoint matching the requested mode - endpoint = SelectEndpointByMode(endpointUrl, securityMode); - } - else - { - endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false); - } - - var endpointConfig = EndpointConfiguration.Create(config); - var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); - - var identity = username != null - ? new UserIdentity(username, password ?? "") - : new UserIdentity(); - - _session = await Session.Create( - config, configuredEndpoint, false, - "OpcUaTestClient", 30000, identity, null); - } - - private static EndpointDescription SelectEndpointByMode(string endpointUrl, MessageSecurityMode mode) - { - using var client = DiscoveryClient.Create(new Uri(endpointUrl)); - var endpoints = client.GetEndpoints(null); - - foreach (var ep in endpoints) - if (ep.SecurityMode == mode && ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) - { - ep.EndpointUrl = endpointUrl; - return ep; - } - - // Fall back to any matching mode - foreach (var ep in endpoints) - if (ep.SecurityMode == mode) - { - ep.EndpointUrl = endpointUrl; - return ep; - } - - throw new InvalidOperationException( - $"No endpoint with security mode {mode} found on {endpointUrl}"); - } - - /// - /// Browse children of a node. Returns list of (DisplayName, NodeId, NodeClass). - /// - /// The node whose hierarchical children should be browsed. - /// The child nodes exposed beneath the requested node. - public async Task> BrowseAsync(NodeId nodeId) - { - var results = new List<(string, NodeId, NodeClass)>(); - var browser = new Browser(Session) - { - NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable, - ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, - IncludeSubtypes = true, - BrowseDirection = BrowseDirection.Forward - }; - - var refs = browser.Browse(nodeId); - foreach (var rd in refs) - results.Add((rd.DisplayName.Text, ExpandedNodeId.ToNodeId(rd.NodeId, Session.NamespaceUris), - rd.NodeClass)); - return results; - } - - /// - /// Read a node's value. - /// - /// The node whose current value should be read from the server. - /// The OPC UA data value returned by the server. - public DataValue Read(NodeId nodeId) - { - return Session.ReadValue(nodeId); - } - - /// - /// Read a specific OPC UA attribute from a node. - /// - /// The node whose attribute should be read. - /// The OPC UA attribute identifier to read. - /// The attribute value returned by the server. - public DataValue ReadAttribute(NodeId nodeId, uint attributeId) - { - var nodesToRead = new ReadValueIdCollection - { - new ReadValueId - { - NodeId = nodeId, - AttributeId = attributeId - } - }; - - Session.Read( - null, - 0, - TimestampsToReturn.Neither, - nodesToRead, - out var results, - out _); - - return results[0]; - } - - /// - /// Write a node's value, optionally using an OPC UA index range for array element writes. - /// Returns the server status code for the write. - /// - /// The node whose value should be written. - /// The value to send to the server. - /// An optional OPC UA index range used for array element writes. - /// The server status code returned for the write request. - public StatusCode Write(NodeId nodeId, object value, string? indexRange = null) - { - var nodesToWrite = new WriteValueCollection - { - new WriteValue - { - NodeId = nodeId, - AttributeId = Attributes.Value, - IndexRange = indexRange, - Value = new DataValue(new Variant(value)) - } - }; - - Session.Write(null, nodesToWrite, out var results, out _); - return results[0]; - } - - /// - /// Create a subscription with a monitored item on the given node. - /// Returns the subscription and monitored item for inspection. - /// - /// The node whose value changes should be monitored. - /// The publishing and sampling interval, in milliseconds, for the test subscription. - /// The created subscription and monitored item pair for later assertions and cleanup. - public async Task<(Subscription Sub, MonitoredItem Item)> SubscribeAsync( - NodeId nodeId, int intervalMs = 250) - { - var subscription = new Subscription(Session.DefaultSubscription) - { - PublishingInterval = intervalMs, - DisplayName = "TestSubscription" - }; - - var item = new MonitoredItem(subscription.DefaultItem) - { - StartNodeId = nodeId, - DisplayName = nodeId.ToString(), - SamplingInterval = intervalMs - }; - - subscription.AddItem(item); - Session.AddSubscription(subscription); - await subscription.CreateAsync(); - - return (subscription, item); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/TestData.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/TestData.cs deleted file mode 100644 index 4a0dfc1..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Helpers/TestData.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Collections.Generic; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers -{ - /// - /// Reusable test data matching the Galaxy hierarchy from gr/layout.md. - /// - public static class TestData - { - /// - /// Creates the standard Galaxy hierarchy used by integration and wiring tests. - /// - /// The standard hierarchy rows for the fake repository. - public static List CreateStandardHierarchy() - { - return new List - { - new() - { - GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, - IsArea = true - }, - new() - { - GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea", - ParentGobjectId = 1, IsArea = true - }, - new() - { - GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001", - BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", - BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false - }, - new() - { - GobjectId = 5, TagName = "MESReceiver_001", ContainedName = "MESReceiver", - BrowseName = "MESReceiver", ParentGobjectId = 3, IsArea = false - } - }; - } - - /// - /// Creates the standard attribute set used by integration and wiring tests. - /// - /// The standard attribute rows for the fake repository. - public static List CreateStandardAttributes() - { - return new List - { - new() - { - GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", - FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineCode", - FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", - FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", - FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false - }, - new() - { - GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInBatchID", - FullTagReference = "MESReceiver_001.MoveInBatchID", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInPartNumbers", - FullTagReference = "MESReceiver_001.MoveInPartNumbers[]", MxDataType = 5, IsArray = true, - ArrayDimension = 50 - } - }; - } - - /// - /// Creates a minimal hierarchy containing a single object for focused unit tests. - /// - /// A minimal hierarchy row set. - public static List CreateMinimalHierarchy() - { - return new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false - } - }; - } - - /// - /// Creates a minimal attribute set containing a single scalar attribute for focused unit tests. - /// - /// A minimal attribute row set. - public static List CreateMinimalAttributes() - { - return new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", - FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false - } - }; - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianAggregateMapTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianAggregateMapTests.cs deleted file mode 100644 index 29e177d..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianAggregateMapTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Historian -{ - public class HistorianAggregateMapTests - { - [Fact] - public void MapAggregateToColumn_Average_ReturnsAverage() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Average).ShouldBe("Average"); - } - - [Fact] - public void MapAggregateToColumn_Minimum_ReturnsMinimum() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Minimum).ShouldBe("Minimum"); - } - - [Fact] - public void MapAggregateToColumn_Maximum_ReturnsMaximum() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Maximum).ShouldBe("Maximum"); - } - - [Fact] - public void MapAggregateToColumn_Count_ReturnsValueCount() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Count).ShouldBe("ValueCount"); - } - - [Fact] - public void MapAggregateToColumn_Start_ReturnsFirst() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Start).ShouldBe("First"); - } - - [Fact] - public void MapAggregateToColumn_End_ReturnsLast() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_End).ShouldBe("Last"); - } - - [Fact] - public void MapAggregateToColumn_StdDev_ReturnsStdDev() - { - HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_StandardDeviationPopulation) - .ShouldBe("StdDev"); - } - - [Fact] - public void MapAggregateToColumn_Unsupported_ReturnsNull() - { - HistorianAggregateMap.MapAggregateToColumn(new NodeId(99999)).ShouldBeNull(); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianPluginLoaderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianPluginLoaderTests.cs deleted file mode 100644 index cba57fb..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianPluginLoaderTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Historian -{ - /// - /// Verifies the load-outcome state machine of . - /// - public class HistorianPluginLoaderTests - { - /// - /// MarkDisabled publishes a Disabled outcome so the dashboard can distinguish - /// "feature off" from "load failed." - /// - [Fact] - public void MarkDisabled_PublishesDisabledOutcome() - { - HistorianPluginLoader.MarkDisabled(); - - HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.Disabled); - HistorianPluginLoader.LastOutcome.Error.ShouldBeNull(); - } - - /// - /// When the plugin directory is missing, TryLoad reports NotFound — not LoadFailed — - /// and returns null so the server can start with history disabled. - /// - [Fact] - public void TryLoad_PluginMissing_ReturnsNullWithNotFoundOutcome() - { - // The test process runs from a bin directory that does not contain a Historian/ - // subfolder, so TryLoad will take the file-missing branch. - var config = new HistorianConfiguration { Enabled = true }; - - var result = HistorianPluginLoader.TryLoad(config); - - result.ShouldBeNull(); - HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.NotFound); - HistorianPluginLoader.LastOutcome.PluginPath.ShouldContain("ZB.MOM.WW.OtOpcUa.Historian.Aveva.dll"); - HistorianPluginLoader.LastOutcome.Error.ShouldBeNull(); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianQualityMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianQualityMappingTests.cs deleted file mode 100644 index 660de8f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistorianQualityMappingTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Historian -{ - public class HistorianQualityMappingTests - { - private static StatusCode MapHistorianQuality(byte quality) - { - return QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality)); - } - - [Theory] - [InlineData(192)] // Quality.Good - [InlineData(216)] // Quality.GoodLocalOverride - public void GoodQualityRange_MapsToGood(byte quality) - { - StatusCode.IsGood(MapHistorianQuality(quality)).ShouldBeTrue(); - } - - [Theory] - [InlineData(64)] // Quality.Uncertain - [InlineData(68)] // Quality.UncertainLastUsable - [InlineData(80)] // Quality.UncertainSensorNotAccurate - [InlineData(88)] // Quality.UncertainSubNormal - [InlineData(128)] // Uncertain range (no exact enum match) - public void UncertainQualityRange_MapsToUncertain(byte quality) - { - StatusCode.IsUncertain(MapHistorianQuality(quality)).ShouldBeTrue(); - } - - [Theory] - [InlineData(0)] // Quality.Bad - [InlineData(1)] // Bad range - [InlineData(4)] // Quality.BadConfigError - [InlineData(8)] // Quality.BadNotConnected - [InlineData(20)] // Quality.BadCommFailure - [InlineData(50)] // Bad range (no exact enum match) - public void BadQualityRange_MapsToBad(byte quality) - { - StatusCode.IsBad(MapHistorianQuality(quality)).ShouldBeTrue(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistoryContinuationPointTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistoryContinuationPointTests.cs deleted file mode 100644 index a4a47f4..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Historian/HistoryContinuationPointTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Historian; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Historian -{ - public class HistoryContinuationPointTests - { - private static List CreateTestValues(int count) - { - var values = new List(); - for (var i = 0; i < count; i++) - values.Add(new DataValue - { - Value = new Variant((double)i), - SourceTimestamp = DateTime.UtcNow.AddSeconds(i), - StatusCode = StatusCodes.Good - }); - return values; - } - - [Fact] - public void Store_ReturnsNonEmptyContinuationPoint() - { - var mgr = new HistoryContinuationPointManager(); - var values = CreateTestValues(5); - - var cp = mgr.Store(values); - - cp.ShouldNotBeNull(); - cp.Length.ShouldBe(16); // GUID = 16 bytes - } - - [Fact] - public void Retrieve_ValidContinuationPoint_ReturnsStoredValues() - { - var mgr = new HistoryContinuationPointManager(); - var values = CreateTestValues(5); - var cp = mgr.Store(values); - - var retrieved = mgr.Retrieve(cp); - - retrieved.ShouldNotBeNull(); - retrieved!.Count.ShouldBe(5); - } - - [Fact] - public void Retrieve_SameContinuationPointTwice_ReturnsNullSecondTime() - { - var mgr = new HistoryContinuationPointManager(); - var values = CreateTestValues(3); - var cp = mgr.Store(values); - - mgr.Retrieve(cp).ShouldNotBeNull(); - mgr.Retrieve(cp).ShouldBeNull(); - } - - [Fact] - public void Retrieve_InvalidBytes_ReturnsNull() - { - var mgr = new HistoryContinuationPointManager(); - - mgr.Retrieve(new byte[] { 1, 2, 3 }).ShouldBeNull(); - } - - [Fact] - public void Retrieve_NullBytes_ReturnsNull() - { - var mgr = new HistoryContinuationPointManager(); - - mgr.Retrieve(null!).ShouldBeNull(); - } - - [Fact] - public void Retrieve_UnknownGuid_ReturnsNull() - { - var mgr = new HistoryContinuationPointManager(); - - mgr.Retrieve(Guid.NewGuid().ToByteArray()).ShouldBeNull(); - } - - [Fact] - public void Release_RemovesContinuationPoint() - { - var mgr = new HistoryContinuationPointManager(); - var values = CreateTestValues(5); - var cp = mgr.Store(values); - - mgr.Release(cp); - - mgr.Retrieve(cp).ShouldBeNull(); - } - - [Fact] - public void Retrieve_ExpiredContinuationPoint_ReturnsNull() - { - var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1)); - var values = CreateTestValues(5); - var cp = mgr.Store(values); - - System.Threading.Thread.Sleep(50); - - mgr.Retrieve(cp).ShouldBeNull(); - } - - [Fact] - public void Release_PurgesExpiredEntries() - { - var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1)); - var cp1 = mgr.Store(CreateTestValues(3)); - var cp2 = mgr.Store(CreateTestValues(5)); - - System.Threading.Thread.Sleep(50); - - // Release one — purge should clean both expired entries - mgr.Release(cp1); - mgr.Retrieve(cp2).ShouldBeNull(); - } - - [Fact] - public void MultipleContinuationPoints_IndependentRetrieval() - { - var mgr = new HistoryContinuationPointManager(); - var values1 = CreateTestValues(3); - var values2 = CreateTestValues(7); - - var cp1 = mgr.Store(values1); - var cp2 = mgr.Store(values2); - - var r1 = mgr.Retrieve(cp1); - var r2 = mgr.Retrieve(cp2); - - r1.ShouldNotBeNull(); - r1!.Count.ShouldBe(3); - r2.ShouldNotBeNull(); - r2!.Count.ShouldBe(7); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AccessLevelTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AccessLevelTests.cs deleted file mode 100644 index b8f8f09..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AccessLevelTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - public class AccessLevelTests - { - private static FakeGalaxyRepository CreateRepoWithSecurityLevels() - { - return new FakeGalaxyRepository - { - Hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "FreeAttr", - FullTagReference = "TestObj.FreeAttr", MxDataType = 5, SecurityClassification = 0 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "OperateAttr", - FullTagReference = "TestObj.OperateAttr", MxDataType = 5, SecurityClassification = 1 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "SecuredAttr", - FullTagReference = "TestObj.SecuredAttr", MxDataType = 5, SecurityClassification = 2 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "VerifiedAttr", - FullTagReference = "TestObj.VerifiedAttr", MxDataType = 5, SecurityClassification = 3 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "TuneAttr", - FullTagReference = "TestObj.TuneAttr", MxDataType = 5, SecurityClassification = 4 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "ConfigAttr", - FullTagReference = "TestObj.ConfigAttr", MxDataType = 5, SecurityClassification = 5 - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "ViewOnlyAttr", - FullTagReference = "TestObj.ViewOnlyAttr", MxDataType = 5, SecurityClassification = 6 - } - } - }; - } - - /// - /// Verifies that writable Galaxy security classifications publish OPC UA variables with read-write access. - /// - [Fact] - public async Task ReadWriteAttribute_HasCurrentReadOrWrite_AccessLevel() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - foreach (var attrName in new[] { "FreeAttr", "OperateAttr", "TuneAttr", "ConfigAttr" }) - { - var nodeId = client.MakeNodeId($"TestObj.{attrName}"); - var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel); - ((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentReadOrWrite, - $"{attrName} should be ReadWrite"); - } - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that secured and view-only Galaxy classifications publish OPC UA variables with read-only access. - /// - [Fact] - public async Task ReadOnlyAttribute_HasCurrentRead_AccessLevel() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - foreach (var attrName in new[] { "SecuredAttr", "VerifiedAttr", "ViewOnlyAttr" }) - { - var nodeId = client.MakeNodeId($"TestObj.{attrName}"); - var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel); - ((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentRead, - $"{attrName} should be ReadOnly"); - } - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that the bridge rejects writes against Galaxy attributes whose security classification is read-only. - /// - [Fact] - public async Task Write_ToReadOnlyAttribute_IsRejected() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("TestObj.ViewOnlyAttr"); - var result = client.Write(nodeId, "test"); - StatusCode.IsBad(result).ShouldBeTrue("Write to ReadOnly attribute should be rejected"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that writes succeed for Galaxy attributes whose security classification permits operator updates. - /// - [Fact] - public async Task Write_ToReadWriteAttribute_Succeeds() - { - var mxClient = new FakeMxAccessClient(); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient, CreateRepoWithSecurityLevels()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("TestObj.OperateAttr"); - var result = client.Write(nodeId, "test"); - StatusCode.IsGood(result).ShouldBeTrue("Write to ReadWrite attribute should succeed"); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AddressSpaceRebuildTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AddressSpaceRebuildTests.cs deleted file mode 100644 index 246465e..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AddressSpaceRebuildTests.cs +++ /dev/null @@ -1,382 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - /// - /// Integration tests verifying dynamic address space changes via a real OPC UA client. - /// Tests browse, subscribe, add/remove nodes at runtime, and subscription quality changes. - /// - public class AddressSpaceRebuildTests - { - /// - /// Confirms that the initial browsed hierarchy matches the seeded Galaxy model. - /// - [Fact] - public async Task Browse_ReturnsInitialHierarchy() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Browse from ZB root - var zbNode = client.MakeNodeId("ZB"); - var children = await client.BrowseAsync(zbNode); - - children.ShouldContain(c => c.Name == "DEV"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients. - /// - [Fact] - public async Task Browse_AfterAddingObject_NewNodeAppears() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Verify initial state — browse TestMachine_001 - var machineNode = client.MakeNodeId("TestMachine_001"); - var initialChildren = await client.BrowseAsync(machineNode); - initialChildren.ShouldNotContain(c => c.Name == "NewReceiver"); - - // Add a new object to the hierarchy - fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo - { - GobjectId = 100, TagName = "NewReceiver_001", - ContainedName = "NewReceiver", BrowseName = "NewReceiver", - ParentGobjectId = 3, IsArea = false // parent = TestMachine_001 - }); - fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo - { - GobjectId = 100, TagName = "NewReceiver_001", - AttributeName = "NewAttr", FullTagReference = "NewReceiver_001.NewAttr", - MxDataType = 5, IsArray = false - }); - - // Trigger rebuild - fixture.Service.TriggerRebuild(); - await Task.Delay(500); // allow rebuild to complete - - // Browse again — new node should appear - var updatedChildren = await client.BrowseAsync(machineNode); - updatedChildren.ShouldContain(c => c.Name == "NewReceiver"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy. - /// - [Fact] - public async Task Browse_AfterRemovingObject_NodeDisappears() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Verify MESReceiver exists initially - var machineNode = client.MakeNodeId("TestMachine_001"); - var initialChildren = await client.BrowseAsync(machineNode); - initialChildren.ShouldContain(c => c.Name == "MESReceiver"); - - // Remove MESReceiver and its attributes from hierarchy - fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); - fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); - - // Trigger rebuild - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - // Browse again — MESReceiver should be gone - var updatedChildren = await client.BrowseAsync(machineNode); - updatedChildren.ShouldNotContain(c => c.Name == "MESReceiver"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild. - /// - [Fact] - public async Task Subscribe_RemovedNode_PublishesBadQuality() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Subscribe to an attribute that will be removed - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInBatchID"); - var (sub, item) = await client.SubscribeAsync(nodeId, 100); - - // Collect notifications - var notifications = new List(); - item.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) - notifications.Add(n); - }; - - await Task.Delay(500); // let initial subscription settle - - // Remove MESReceiver and its attributes - fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); - fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); - - // Trigger rebuild — nodes get deleted - fixture.Service.TriggerRebuild(); - - // Wait for publish cycle to deliver Bad status - await Task.Delay(2000); - - // The subscription should have received a Bad quality notification - // after the node was deleted during rebuild - notifications.ShouldContain(n => StatusCode.IsBad(n.Value.StatusCode)); - - await sub.DeleteAsync(true); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild. - /// - [Fact] - public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Subscribe to an attribute that will survive the rebuild - var nodeId = client.MakeNodeId("TestMachine_001.MachineID"); - var (sub, item) = await client.SubscribeAsync(nodeId, 100); - - await Task.Delay(500); - - // Remove only MESReceiver (MachineID on TestMachine_001 survives) - fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); - fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); - - fixture.Service.TriggerRebuild(); - await Task.Delay(1000); - - // The surviving node should still be browsable - var machineNode = client.MakeNodeId("TestMachine_001"); - var children = await client.BrowseAsync(machineNode); - children.ShouldContain(c => c.Name == "MachineID"); - - await sub.DeleteAsync(true); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable. - /// - [Fact] - public async Task Browse_AddAttribute_NewVariableAppears() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var machineNode = client.MakeNodeId("TestMachine_001"); - var initialChildren = await client.BrowseAsync(machineNode); - initialChildren.ShouldNotContain(c => c.Name == "NewSensor"); - - // Add a new attribute - fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo - { - GobjectId = 3, TagName = "TestMachine_001", - AttributeName = "NewSensor", FullTagReference = "TestMachine_001.NewSensor", - MxDataType = 4, IsArray = false // Double - }); - - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - var updatedChildren = await client.BrowseAsync(machineNode); - updatedChildren.ShouldContain(c => c.Name == "NewSensor"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable. - /// - [Fact] - public async Task Browse_RemoveAttribute_VariableDisappears() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var machineNode = client.MakeNodeId("TestMachine_001"); - var initialChildren = await client.BrowseAsync(machineNode); - initialChildren.ShouldContain(c => c.Name == "MachineCode"); - - // Remove MachineCode attribute - fixture.GalaxyRepository!.Attributes.RemoveAll(a => - a.TagName == "TestMachine_001" && a.AttributeName == "MachineCode"); - - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - var updatedChildren = await client.BrowseAsync(machineNode); - updatedChildren.ShouldNotContain(c => c.Name == "MachineCode"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh. - /// - [Fact] - public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - var mxClient = fixture.MxAccessClient!; - - nodeManager.SubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - fixture.Service.TriggerRebuild(); - await Task.Delay(200); - - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(0); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local - /// subscription state. - /// - [Fact] - public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - var mxClient = fixture.MxAccessClient!; - - nodeManager.RestoreTransferredSubscriptions(new[] - { - "TestMachine_001.MachineID", - "TestMachine_001.MachineID" - }); - - await Task.Delay(100); - - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(0); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory. - /// - [Fact] - public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - var mxClient = fixture.MxAccessClient!; - - nodeManager.SubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - nodeManager.RestoreTransferredSubscriptions(new[] - { - "TestMachine_001.MachineID" - }); - - await Task.Delay(100); - - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(0); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AlarmObjectFilterIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AlarmObjectFilterIntegrationTests.cs deleted file mode 100644 index b5033d7..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/AlarmObjectFilterIntegrationTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - /// - /// End-to-end integration tests that boot a real LmxNodeManager against fake Galaxy data and verify - /// the template-based alarm object filter actually suppresses alarm condition creation in both the - /// full build path and the subtree rebuild path after a simulated Galaxy redeploy. - /// - public class AlarmObjectFilterIntegrationTests - { - [Fact] - public async Task Filter_Empty_AllAlarmsTracked() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - repo: CreateRepoWithMixedTemplates(), - alarmTrackingEnabled: true); - await fixture.InitializeAsync(); - try - { - fixture.NodeManager.ShouldNotBeNull(); - // Two alarm attributes total (one per object), no filter → both tracked. - fixture.NodeManager!.AlarmConditionCount.ShouldBe(2); - fixture.NodeManager.AlarmFilterEnabled.ShouldBeFalse(); - fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Filter_MatchesOneTemplate_OnlyMatchingAlarmTracked() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - repo: CreateRepoWithMixedTemplates(), - alarmTrackingEnabled: true, - alarmObjectFilters: new[] { "TestMachine*" }); - await fixture.InitializeAsync(); - try - { - fixture.NodeManager.ShouldNotBeNull(); - fixture.NodeManager!.AlarmFilterEnabled.ShouldBeTrue(); - fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1); - fixture.NodeManager.AlarmConditionCount.ShouldBe(1); - fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(1); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Filter_MatchesParent_PropagatesToChild() - { - var attrs = new List(); - attrs.AddRange(AlarmWithInAlarm(1, "Parent_001", "AlarmA")); - attrs.AddRange(AlarmWithInAlarm(2, "Child_001", "AlarmB")); - - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - Obj(1, parent: 0, tag: "Parent_001", template: "TestMachine"), - Obj(2, parent: 1, tag: "Child_001", template: "UnrelatedWidget") - }, - Attributes = attrs - }; - - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - repo: repo, - alarmTrackingEnabled: true, - alarmObjectFilters: new[] { "TestMachine*" }); - await fixture.InitializeAsync(); - try - { - fixture.NodeManager!.AlarmConditionCount.ShouldBe(2); - fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(2); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Filter_NoMatch_ZeroAlarmConditions() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - repo: CreateRepoWithMixedTemplates(), - alarmTrackingEnabled: true, - alarmObjectFilters: new[] { "NotInGalaxy*" }); - await fixture.InitializeAsync(); - try - { - fixture.NodeManager!.AlarmConditionCount.ShouldBe(0); - fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0); - fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Filter_GalaxyDollarPrefix_Normalized() - { - // Template chain stored as "$TestMachine" must match operator pattern "TestMachine*". - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - Obj(1, parent: 0, tag: "Obj_1", template: "$TestMachine") - }, - Attributes = new List(AlarmWithInAlarm(1, "Obj_1", "AlarmX")) - }; - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - repo: repo, - alarmTrackingEnabled: true, - alarmObjectFilters: new[] { "TestMachine*" }); - await fixture.InitializeAsync(); - try - { - fixture.NodeManager!.AlarmConditionCount.ShouldBe(1); - } - finally - { - await fixture.DisposeAsync(); - } - } - - // ---------- Helpers ---------- - - private static FakeGalaxyRepository CreateRepoWithMixedTemplates() - { - var attrs = new List(); - attrs.AddRange(AlarmWithInAlarm(1, "TestMachine_001", "MachineAlarm")); - attrs.AddRange(AlarmWithInAlarm(2, "Pump_001", "PumpAlarm")); - return new FakeGalaxyRepository - { - Hierarchy = new List - { - Obj(1, parent: 0, tag: "TestMachine_001", template: "TestMachine"), - Obj(2, parent: 0, tag: "Pump_001", template: "Pump") - }, - Attributes = attrs - }; - } - - /// - /// Builds a Galaxy alarm attribute plus its companion .InAlarm sub-attribute. The alarm - /// creation path in LmxNodeManager skips objects whose alarm attribute has no InAlarm variable - /// in the tag→node map, so tests must include both rows for alarm conditions to materialize. - /// - private static IEnumerable AlarmWithInAlarm(int gobjectId, string tag, string attrName) - { - yield return new GalaxyAttributeInfo - { - GobjectId = gobjectId, - TagName = tag, - AttributeName = attrName, - FullTagReference = $"{tag}.{attrName}", - MxDataType = 1, - IsAlarm = true - }; - yield return new GalaxyAttributeInfo - { - GobjectId = gobjectId, - TagName = tag, - AttributeName = attrName + ".InAlarm", - FullTagReference = $"{tag}.{attrName}.InAlarm", - MxDataType = 1, - IsAlarm = false - }; - } - - private static GalaxyObjectInfo Obj(int id, int parent, string tag, string template) => new() - { - GobjectId = id, - ParentGobjectId = parent, - TagName = tag, - ContainedName = tag, - BrowseName = tag, - IsArea = false, - TemplateChain = new List { template } - }; - - private static GalaxyAttributeInfo AlarmAttr(int gobjectId, string tag, string attrName) => new() - { - GobjectId = gobjectId, - TagName = tag, - AttributeName = attrName, - FullTagReference = $"{tag}.{attrName}", - MxDataType = 1, - IsAlarm = true - }; - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/ArrayWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/ArrayWriteTests.cs deleted file mode 100644 index 96cf5f5..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/ArrayWriteTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.Collections.Concurrent; -using System.Linq; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - /// - /// Verifies OPC UA indexed array writes against the bridge's whole-array runtime update behavior. - /// - public class ArrayWriteTests - { - /// - /// Confirms that writing a single array element updates the correct slot while preserving the rest of the array. - /// - [Fact] - public async Task Write_SingleArrayElement_UpdatesWholeArrayValue() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good( - Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray()); - - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); - - var before = client.Read(nodeId).Value as string[]; - before.ShouldNotBeNull(); - before.Length.ShouldBe(50); - before[1].ShouldBe("PART-01"); - - var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1"); - StatusCode.IsGood(status).ShouldBe(true); - - var after = client.Read(nodeId).Value as string[]; - after.ShouldNotBeNull(); - after.Length.ShouldBe(50); - after[0].ShouldBe("PART-00"); - after[1].ShouldBe("UPDATED-PART"); - after[2].ShouldBe("PART-02"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array - /// metadata. - /// - [Fact] - public async Task ArrayNode_UsesBracketlessNodeId_AndPublishesArrayDimensions() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good( - Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray()); - - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); - - var value = client.Read(nodeId).Value as string[]; - value.ShouldNotBeNull(); - value.Length.ShouldBe(50); - - var valueRank = client.ReadAttribute(nodeId, Attributes.ValueRank).Value; - valueRank.ShouldBe(ValueRanks.OneDimension); - - var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[]; - dimensions.ShouldNotBeNull(); - dimensions.ShouldBe(new uint[] { 50 }); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that a null runtime value for a statically sized array is exposed as a typed fixed-length array. - /// - [Fact] - public async Task Read_NullStaticArray_ReturnsDefaultTypedArray() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); - - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); - - var value = client.Read(nodeId).Value as string[]; - value.ShouldNotBeNull(); - value.Length.ShouldBe(50); - value.ShouldAllBe(v => v == string.Empty); - - var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[]; - dimensions.ShouldBe(new uint[] { 50 }); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients. - /// - [Fact] - public async Task Write_SingleArrayElement_PublishesUpdatedArrayToSubscribers() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good( - Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray()); - - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); - var notifications = new ConcurrentBag(); - var (sub, item) = await client.SubscribeAsync(nodeId, 100); - item.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification notification) - notifications.Add(notification); - }; - - await Task.Delay(500); - - var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1"); - StatusCode.IsGood(status).ShouldBe(true); - - await Task.Delay(1000); - - notifications.Any(n => - n.Value.Value is string[] values && - values.Length == 50 && - values[0] == "PART-00" && - values[1] == "UPDATED-PART" && - values[2] == "PART-02").ShouldBe(true); - - await sub.DeleteAsync(true); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that indexed writes succeed even when the current runtime array value is null. - /// - [Fact] - public async Task Write_SingleArrayElement_WhenCurrentArrayIsNull_UsesDefaultArray() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); - - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); - var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1"); - StatusCode.IsGood(status).ShouldBe(true); - - var after = client.Read(nodeId).Value as string[]; - after.ShouldNotBeNull(); - after.Length.ShouldBe(50); - after[0].ShouldBe(string.Empty); - after[1].ShouldBe("UPDATED-PART"); - after[2].ShouldBe(string.Empty); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/HistorizingFlagTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/HistorizingFlagTests.cs deleted file mode 100644 index 9362c0f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/HistorizingFlagTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - public class HistorizingFlagTests - { - private static FakeGalaxyRepository CreateRepo() - { - return new FakeGalaxyRepository - { - Hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "HistorizedAttr", - FullTagReference = "TestObj.HistorizedAttr", MxDataType = 2, IsHistorized = true - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "NormalAttr", - FullTagReference = "TestObj.NormalAttr", MxDataType = 5, IsHistorized = false - }, - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "AlarmAttr", - FullTagReference = "TestObj.AlarmAttr", MxDataType = 1, IsAlarm = true - } - } - }; - } - - /// - /// Verifies that historized Galaxy attributes advertise OPC UA historizing support and history-read access. - /// - [Fact] - public async Task HistorizedAttribute_HasHistorizingTrue_AndHistoryReadAccess() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("TestObj.HistorizedAttr"); - var historizing = client.ReadAttribute(nodeId, Attributes.Historizing); - ((bool)historizing.Value).ShouldBeTrue(); - - var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel); - var level = (byte)accessLevel.Value; - (level & AccessLevels.HistoryRead).ShouldBe(AccessLevels.HistoryRead, - "HistoryRead bit should be set"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that non-historized Galaxy attributes do not claim OPC UA history support. - /// - [Fact] - public async Task NormalAttribute_HasHistorizingFalse_AndNoHistoryReadAccess() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var nodeId = client.MakeNodeId("TestObj.NormalAttr"); - var historizing = client.ReadAttribute(nodeId, Attributes.Historizing); - ((bool)historizing.Value).ShouldBeFalse(); - - var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel); - var level = (byte)accessLevel.Value; - (level & AccessLevels.HistoryRead).ShouldBe(0, - "HistoryRead bit should not be set"); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/IncrementalSyncTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/IncrementalSyncTests.cs deleted file mode 100644 index dacc3b2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/IncrementalSyncTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - public class IncrementalSyncTests - { - /// - /// Verifies that adding a new Galaxy object and attribute causes the corresponding OPC UA node subtree to appear after - /// sync. - /// - [Fact] - public async Task Sync_AddObject_NewNodeAppears() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Verify initial state - var children = await client.BrowseAsync(client.MakeNodeId("TestArea")); - children.Select(c => c.Name).ShouldContain("TestMachine_001"); - children.Select(c => c.Name).ShouldNotContain("NewObject"); - - // Add a new object - fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo - { - GobjectId = 100, TagName = "NewObject_001", ContainedName = "NewObject", - BrowseName = "NewObject", ParentGobjectId = 2, IsArea = false - }); - fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo - { - GobjectId = 100, TagName = "NewObject_001", AttributeName = "Status", - FullTagReference = "NewObject_001.Status", MxDataType = 5 - }); - - fixture.Service.TriggerRebuild(); - await Task.Delay(1000); - - // Reconnect in case session was disrupted during rebuild - using var client2 = new OpcUaTestClient(); - await client2.ConnectAsync(fixture.EndpointUrl); - - // New object should appear when browsing parent - children = await client2.BrowseAsync(client2.MakeNodeId("TestArea")); - children.Select(c => c.Name).ShouldContain("NewObject", - $"Browse returned: [{string.Join(", ", children.Select(c => c.Name))}]"); - - // Original object should still be there - children.Select(c => c.Name).ShouldContain("TestMachine_001"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that removing a Galaxy object tears down the corresponding OPC UA subtree without affecting siblings. - /// - [Fact] - public async Task Sync_RemoveObject_NodeDisappears() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Verify MESReceiver exists - var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001")); - children.Select(c => c.Name).ShouldContain("MESReceiver"); - - // Remove MESReceiver (gobject_id 5) and its attributes - fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.GobjectId == 5); - fixture.GalaxyRepository.Attributes.RemoveAll(a => a.GobjectId == 5); - - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - // MESReceiver should be gone - children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001")); - children.Select(c => c.Name).ShouldNotContain("MESReceiver"); - - // DelmiaReceiver should still be there - children.Select(c => c.Name).ShouldContain("DelmiaReceiver"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that adding a Galaxy attribute creates a new OPC UA variable during incremental rebuild. - /// - [Fact] - public async Task Sync_AddAttribute_NewVariableAppears() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Add a new attribute to TestMachine_001 - fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo - { - GobjectId = 3, TagName = "TestMachine_001", AttributeName = "NewAttr", - FullTagReference = "TestMachine_001.NewAttr", MxDataType = 5 - }); - - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001")); - children.Select(c => c.Name).ShouldContain("NewAttr"); - children.Select(c => c.Name).ShouldContain("MachineID"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that subscriptions on unchanged objects continue receiving data after unrelated subtree rebuilds. - /// - [Fact] - public async Task Sync_UnchangedObject_SubscriptionSurvives() - { - var mxClient = new FakeMxAccessClient(); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Subscribe to MachineID on TestMachine_001 - var nodeId = client.MakeNodeId("TestMachine_001.MachineID"); - var (sub, item) = await client.SubscribeAsync(nodeId); - await Task.Delay(500); - - // Modify a DIFFERENT object (MESReceiver) — TestMachine_001 should be unaffected - var mesAttr = fixture.GalaxyRepository!.Attributes - .First(a => a.GobjectId == 5 && a.AttributeName == "MoveInBatchID"); - mesAttr.SecurityClassification = 2; // change something - - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - // Push a value change through MXAccess — subscription should still deliver - mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good("UPDATED")); - await Task.Delay(1000); - - var lastValue = (item.LastValue as MonitoredItemNotification)?.Value?.Value; - lastValue.ShouldBe("UPDATED"); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Verifies that a rebuild request with no repository changes leaves the published namespace intact. - /// - [Fact] - public async Task Sync_NoChanges_NothingHappens() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - // Trigger rebuild with no changes - fixture.Service.TriggerRebuild(); - await Task.Delay(500); - - // Everything should still work - var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001")); - children.Select(c => c.Name).ShouldContain("MachineID"); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/MultiClientTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/MultiClientTests.cs deleted file mode 100644 index 54fb614..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/MultiClientTests.cs +++ /dev/null @@ -1,430 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Opc.Ua; -using Opc.Ua.Client; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - /// - /// Integration tests verifying multi-client subscription sync and concurrent operations. - /// - public class MultiClientTests - { - // ── Subscription Sync ───────────────────────────────────────────── - - /// - /// Confirms that multiple OPC UA clients subscribed to the same tag all receive the same runtime update. - /// - [Fact] - public async Task MultipleClients_SubscribeToSameTag_AllReceiveDataChanges() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var clients = new List(); - var notifications = new ConcurrentDictionary>(); - var subscriptions = new List(); - - for (var i = 0; i < 3; i++) - { - var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - clients.Add(client); - - var nodeId = client.MakeNodeId("TestMachine_001.MachineID"); - var (sub, item) = await client.SubscribeAsync(nodeId, 100); - subscriptions.Add(sub); - - var clientIndex = i; - notifications[clientIndex] = new List(); - item.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) - notifications[clientIndex].Add(n); - }; - } - - await Task.Delay(500); // let subscriptions settle - - // Simulate data change - fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "MACHINE_42"); - await Task.Delay(1000); // let publish cycle deliver - - // All 3 clients should have received the notification - for (var i = 0; i < 3; i++) - notifications[i].Count.ShouldBeGreaterThan(0, $"Client {i} did not receive notification"); - - foreach (var sub in subscriptions) await sub.DeleteAsync(true); - foreach (var c in clients) c.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that one client disconnecting does not stop remaining clients from receiving updates. - /// - [Fact] - public async Task Client_Disconnects_OtherClientsStillReceive() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var client1 = new OpcUaTestClient(); - var client2 = new OpcUaTestClient(); - var client3 = new OpcUaTestClient(); - await client1.ConnectAsync(fixture.EndpointUrl); - await client2.ConnectAsync(fixture.EndpointUrl); - await client3.ConnectAsync(fixture.EndpointUrl); - - var notifications1 = new ConcurrentBag(); - var notifications3 = new ConcurrentBag(); - - var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100); - var (sub2, _) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100); - var (sub3, item3) = await client3.SubscribeAsync(client3.MakeNodeId("TestMachine_001.MachineID"), 100); - - item1.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n); - }; - item3.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) notifications3.Add(n); - }; - - await Task.Delay(500); - - // Disconnect client 2 - client2.Dispose(); - - await Task.Delay(500); // let server process disconnect - - // Simulate data change — should not crash, clients 1+3 should still receive - fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_DISCONNECT"); - await Task.Delay(1000); - - notifications1.Count.ShouldBeGreaterThan(0, - "Client 1 should still receive after client 2 disconnected"); - notifications3.Count.ShouldBeGreaterThan(0, - "Client 3 should still receive after client 2 disconnected"); - - await sub1.DeleteAsync(true); - await sub3.DeleteAsync(true); - client1.Dispose(); - client3.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that one client unsubscribing does not interrupt delivery to other subscribed clients. - /// - [Fact] - public async Task Client_Unsubscribes_OtherClientsStillReceive() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var client1 = new OpcUaTestClient(); - var client2 = new OpcUaTestClient(); - await client1.ConnectAsync(fixture.EndpointUrl); - await client2.ConnectAsync(fixture.EndpointUrl); - - var notifications2 = new ConcurrentBag(); - - var (sub1, _) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100); - var (sub2, item2) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100); - item2.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n); - }; - - await Task.Delay(500); - - // Client 1 unsubscribes - await sub1.DeleteAsync(true); - await Task.Delay(500); - - // Simulate data change — client 2 should still receive - fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_UNSUB"); - await Task.Delay(1000); - - notifications2.Count.ShouldBeGreaterThan(0, - "Client 2 should still receive after client 1 unsubscribed"); - - await sub2.DeleteAsync(true); - client1.Dispose(); - client2.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that clients subscribed to different tags only receive updates for their own monitored data. - /// - [Fact] - public async Task MultipleClients_SubscribeToDifferentTags_EachGetsOwnData() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var client1 = new OpcUaTestClient(); - var client2 = new OpcUaTestClient(); - await client1.ConnectAsync(fixture.EndpointUrl); - await client2.ConnectAsync(fixture.EndpointUrl); - - var notifications1 = new ConcurrentBag(); - var notifications2 = new ConcurrentBag(); - - var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100); - var (sub2, item2) = - await client2.SubscribeAsync(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"), 100); - - item1.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n); - }; - item2.Notification += (_, e) => - { - if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n); - }; - - await Task.Delay(500); - - // Only change MachineID - fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "CHANGED"); - await Task.Delay(1000); - - notifications1.Count.ShouldBeGreaterThan(0, "Client 1 should receive MachineID change"); - // Client 2 subscribed to DownloadPath, should NOT receive MachineID change - // (it may have received initial BadWaitingForInitialData, but not the "CHANGED" value) - var client2HasMachineIdValue = notifications2.Any(n => - n.Value.Value is string s && s == "CHANGED"); - client2HasMachineIdValue.ShouldBe(false, "Client 2 should not receive MachineID data"); - - await sub1.DeleteAsync(true); - await sub2.DeleteAsync(true); - client1.Dispose(); - client2.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - // ── Concurrent Operation Tests ──────────────────────────────────── - - /// - /// Confirms that concurrent browse operations from several clients all complete successfully. - /// - [Fact] - public async Task ConcurrentBrowseFromMultipleClients_AllSucceed() - { - // Tests concurrent browse operations from 5 clients — browses don't go through MxAccess - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var clients = new List(); - for (var i = 0; i < 5; i++) - { - var c = new OpcUaTestClient(); - await c.ConnectAsync(fixture.EndpointUrl); - clients.Add(c); - } - - var nodes = new[] - { - "ZB", "TestMachine_001", "DelmiaReceiver_001", - "MESReceiver_001", "TestMachine_001" - }; - - // All 5 clients browse simultaneously - var browseTasks = clients.Select((c, i) => - c.BrowseAsync(c.MakeNodeId(nodes[i]))).ToArray(); - - var results = await Task.WhenAll(browseTasks); - - results.Length.ShouldBe(5); - foreach (var r in results) - r.ShouldNotBeEmpty(); - - foreach (var c in clients) c.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that concurrent browse requests return consistent results across clients. - /// - [Fact] - public async Task ConcurrentBrowse_AllReturnSameResults() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var clients = new List(); - for (var i = 0; i < 5; i++) - { - var c = new OpcUaTestClient(); - await c.ConnectAsync(fixture.EndpointUrl); - clients.Add(c); - } - - // All browse TestMachine_001 simultaneously - var browseTasks = clients.Select(c => - c.BrowseAsync(c.MakeNodeId("TestMachine_001"))).ToArray(); - - var results = await Task.WhenAll(browseTasks); - - // All should get identical child lists - var firstResult = results[0].Select(r => r.Name).OrderBy(n => n).ToList(); - for (var i = 1; i < results.Length; i++) - { - var thisResult = results[i].Select(r => r.Name).OrderBy(n => n).ToList(); - thisResult.ShouldBe(firstResult, $"Client {i} got different browse results"); - } - - foreach (var c in clients) c.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that simultaneous browse and subscribe operations do not interfere with one another. - /// - [Fact] - public async Task ConcurrentBrowseAndSubscribe_NoInterference() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var clients = new List(); - for (var i = 0; i < 4; i++) - { - var c = new OpcUaTestClient(); - await c.ConnectAsync(fixture.EndpointUrl); - clients.Add(c); - } - - // 2 browse + 2 subscribe simultaneously - var tasks = new Task[] - { - clients[0].BrowseAsync(clients[0].MakeNodeId("TestMachine_001")), - clients[1].BrowseAsync(clients[1].MakeNodeId("ZB")), - clients[2].SubscribeAsync(clients[2].MakeNodeId("TestMachine_001.MachineID"), 200), - clients[3].SubscribeAsync(clients[3].MakeNodeId("DelmiaReceiver_001.DownloadPath"), 200) - }; - - await Task.WhenAll(tasks); - // All should complete without errors - - foreach (var c in clients) c.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that concurrent subscribe, read, and browse operations complete without deadlocking the server. - /// - [Fact] - public async Task ConcurrentSubscribeAndRead_NoDeadlock() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - var client1 = new OpcUaTestClient(); - var client2 = new OpcUaTestClient(); - var client3 = new OpcUaTestClient(); - await client1.ConnectAsync(fixture.EndpointUrl); - await client2.ConnectAsync(fixture.EndpointUrl); - await client3.ConnectAsync(fixture.EndpointUrl); - - // All three operate simultaneously — should not deadlock - var timeout = Task.Delay(TimeSpan.FromSeconds(15)); - var operations = Task.WhenAll( - client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 200) - .ContinueWith(t => (object)t.Result), - Task.Run(() => (object)client2.Read(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"))), - client3.BrowseAsync(client3.MakeNodeId("TestMachine_001")) - .ContinueWith(t => (object)t.Result) - ); - - var completed = await Task.WhenAny(operations, timeout); - completed.ShouldBe(operations, "Operations should complete before timeout (possible deadlock)"); - - client1.Dispose(); - client2.Dispose(); - client3.Dispose(); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that repeated client churn does not leave the server in an unstable state. - /// - [Fact] - public async Task RapidConnectDisconnect_ServerStaysStable() - { - var fixture = OpcUaServerFixture.WithFakes(); - await fixture.InitializeAsync(); - try - { - // Rapidly connect, browse, disconnect — 10 iterations - for (var i = 0; i < 10; i++) - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - var children = await client.BrowseAsync(client.MakeNodeId("ZB")); - children.ShouldNotBeEmpty(); - } - - // After all that churn, server should still be responsive - using var finalClient = new OpcUaTestClient(); - await finalClient.ConnectAsync(fixture.EndpointUrl); - var finalChildren = await finalClient.BrowseAsync(finalClient.MakeNodeId("TestMachine_001")); - finalChildren.ShouldContain(c => c.Name == "MachineID"); - finalChildren.ShouldContain(c => c.Name == "DelmiaReceiver"); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/PermissionEnforcementTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/PermissionEnforcementTests.cs deleted file mode 100644 index deccf1e..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/PermissionEnforcementTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - public class PermissionEnforcementTests - { - private static FakeAuthenticationProvider CreateTestAuthProvider() - { - return new FakeAuthenticationProvider() - .AddUser("readonly", "readonly123", AppRoles.ReadOnly) - .AddUser("writeop", "writeop123", AppRoles.WriteOperate) - .AddUser("writetune", "writetune123", AppRoles.WriteTune) - .AddUser("writeconfig", "writeconfig123", AppRoles.WriteConfigure) - .AddUser("alarmack", "alarmack123", AppRoles.AlarmAck) - .AddUser("admin", "admin123", AppRoles.ReadOnly, AppRoles.WriteOperate, AppRoles.WriteTune, - AppRoles.WriteConfigure, AppRoles.AlarmAck); - } - - private static AuthenticationConfiguration CreateAuthConfig(bool anonymousCanWrite = false) - { - return new AuthenticationConfiguration - { - AllowAnonymous = true, - AnonymousCanWrite = anonymousCanWrite - }; - } - - [Fact] - public async Task AnonymousRead_Allowed() - { - var mxClient = new FakeMxAccessClient(); - mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("hello"); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - mxClient, - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var result = client.Read(client.MakeNodeId("TestMachine_001.MachineID")); - result.StatusCode.ShouldNotBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task AnonymousWrite_Denied_WhenAnonymousCanWriteFalse() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - authConfig: CreateAuthConfig(false), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task AnonymousWrite_Allowed_WhenAnonymousCanWriteTrue() - { - var mxClient = new FakeMxAccessClient(); - mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial"); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - mxClient, - authConfig: CreateAuthConfig(true), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task ReadOnlyUser_Write_Denied() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "readonly123"); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task WriteOperateUser_Write_Allowed() - { - var mxClient = new FakeMxAccessClient(); - mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial"); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - mxClient, - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl, username: "writeop", password: "writeop123"); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task AlarmAckOnlyUser_Write_Denied() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl, username: "alarmack", password: "alarmack123"); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task AdminUser_Write_Allowed() - { - var mxClient = new FakeMxAccessClient(); - mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial"); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - mxClient, - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl, username: "admin", password: "admin123"); - - var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test"); - status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task InvalidPassword_ConnectionRejected() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - authConfig: CreateAuthConfig(), - authProvider: CreateTestAuthProvider()); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - - await Should.ThrowAsync(async () => - await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "wrongpassword")); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/RedundancyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/RedundancyTests.cs deleted file mode 100644 index e3dc040..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Integration/RedundancyTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Integration -{ - public class RedundancyTests - { - [Fact] - public async Task Server_WithRedundancyDisabled_ReportsNone() - { - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport); - ((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.None); - - var serviceLevel = client.Read(VariableIds.Server_ServiceLevel); - ((byte)serviceLevel.Value).ShouldBe((byte)255); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Server_WithRedundancyEnabled_ReportsConfiguredMode() - { - var redundancy = new RedundancyConfiguration - { - Enabled = true, - Mode = "Warm", - Role = "Primary", - ServiceLevelBase = 200, - ServerUris = new List { "urn:test:primary", "urn:test:secondary" } - }; - - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: redundancy, - applicationUri: "urn:test:primary"); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport); - ((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.Warm); - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task Server_Primary_HasHigherServiceLevel_ThanSecondary() - { - var sharedUris = new List { "urn:test:primary", "urn:test:secondary" }; - - var primaryRedundancy = new RedundancyConfiguration - { - Enabled = true, Mode = "Warm", Role = "Primary", - ServiceLevelBase = 200, ServerUris = sharedUris - }; - var secondaryRedundancy = new RedundancyConfiguration - { - Enabled = true, Mode = "Warm", Role = "Secondary", - ServiceLevelBase = 200, ServerUris = sharedUris - }; - - var primaryFixture = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: primaryRedundancy, applicationUri: "urn:test:primary"); - var secondaryFixture = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: secondaryRedundancy, applicationUri: "urn:test:secondary", - serverName: "TestGalaxy2"); - - await primaryFixture.InitializeAsync(); - await secondaryFixture.InitializeAsync(); - try - { - using var primaryClient = new OpcUaTestClient(); - await primaryClient.ConnectAsync(primaryFixture.EndpointUrl); - var primaryLevel = (byte)primaryClient.Read(VariableIds.Server_ServiceLevel).Value; - - using var secondaryClient = new OpcUaTestClient(); - await secondaryClient.ConnectAsync(secondaryFixture.EndpointUrl); - var secondaryLevel = (byte)secondaryClient.Read(VariableIds.Server_ServiceLevel).Value; - - primaryLevel.ShouldBeGreaterThan(secondaryLevel); - } - finally - { - await secondaryFixture.DisposeAsync(); - await primaryFixture.DisposeAsync(); - } - } - - [Fact] - public async Task Server_WithRedundancyEnabled_ExposesServerUriArray() - { - var serverUris = new List { "urn:test:server1", "urn:test:server2" }; - var redundancy = new RedundancyConfiguration - { - Enabled = true, Mode = "Warm", Role = "Primary", - ServiceLevelBase = 200, ServerUris = serverUris - }; - - var fixture = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: redundancy, applicationUri: "urn:test:server1"); - await fixture.InitializeAsync(); - try - { - using var client = new OpcUaTestClient(); - await client.ConnectAsync(fixture.EndpointUrl); - - var uriArrayValue = client.Read(VariableIds.Server_ServerRedundancy_ServerUriArray); - - // ServerUriArray may not be exposed if the SDK doesn't create the non-transparent - // redundancy node type automatically. If the value is null, the server logged a - // warning and the test is informational rather than a hard failure. - if (uriArrayValue.Value != null) - { - var uris = (string[])uriArrayValue.Value; - uris.Length.ShouldBe(2); - uris.ShouldContain("urn:test:server1"); - uris.ShouldContain("urn:test:server2"); - } - } - finally - { - await fixture.DisposeAsync(); - } - } - - [Fact] - public async Task TwoServers_BothExposeSameRedundantSet() - { - var sharedUris = new List { "urn:test:a", "urn:test:b" }; - var configA = new RedundancyConfiguration - { - Enabled = true, Mode = "Warm", Role = "Primary", - ServiceLevelBase = 200, ServerUris = sharedUris - }; - var configB = new RedundancyConfiguration - { - Enabled = true, Mode = "Warm", Role = "Secondary", - ServiceLevelBase = 200, ServerUris = sharedUris - }; - - var fixtureA = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: configA, applicationUri: "urn:test:a"); - var fixtureB = OpcUaServerFixture.WithFakeMxAccessClient( - redundancy: configB, applicationUri: "urn:test:b", - serverName: "TestGalaxy2"); - - await fixtureA.InitializeAsync(); - await fixtureB.InitializeAsync(); - try - { - using var clientA = new OpcUaTestClient(); - await clientA.ConnectAsync(fixtureA.EndpointUrl); - var modeA = (int)clientA.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value; - - using var clientB = new OpcUaTestClient(); - await clientB.ConnectAsync(fixtureB.EndpointUrl); - var modeB = (int)clientB.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value; - - modeA.ShouldBe((int)RedundancySupport.Warm); - modeB.ShouldBe((int)RedundancySupport.Warm); - } - finally - { - await fixtureB.DisposeAsync(); - await fixtureA.DisposeAsync(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Metrics/PerformanceMetricsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Metrics/PerformanceMetricsTests.cs deleted file mode 100644 index a6b9ff2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Metrics/PerformanceMetricsTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.Threading; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Metrics -{ - /// - /// Verifies operation timing aggregation, rolling buffers, and success tracking used by the bridge metrics subsystem. - /// - public class PerformanceMetricsTests - { - /// - /// Confirms that a fresh metrics collector reports no statistics. - /// - [Fact] - public void EmptyState_ReturnsZeroStatistics() - { - using var metrics = new PerformanceMetrics(); - var stats = metrics.GetStatistics(); - stats.ShouldBeEmpty(); - } - - /// - /// Confirms that repeated operation recordings update total and successful execution counts. - /// - [Fact] - public void RecordOperation_TracksCounts() - { - using var metrics = new PerformanceMetrics(); - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10)); - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(20), false); - - var stats = metrics.GetStatistics(); - stats.ShouldContainKey("Read"); - stats["Read"].TotalCount.ShouldBe(2); - stats["Read"].SuccessCount.ShouldBe(1); - stats["Read"].SuccessRate.ShouldBe(0.5); - } - - /// - /// Confirms that min, max, and average timing values are calculated from recorded operations. - /// - [Fact] - public void RecordOperation_TracksMinMaxAverage() - { - using var metrics = new PerformanceMetrics(); - metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(10)); - metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(30)); - metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20)); - - var stats = metrics.GetStatistics()["Write"]; - stats.MinMilliseconds.ShouldBe(10); - stats.MaxMilliseconds.ShouldBe(30); - stats.AverageMilliseconds.ShouldBe(20); - } - - /// - /// Confirms that the 95th percentile is calculated from the recorded timing sample. - /// - [Fact] - public void P95_CalculatedCorrectly() - { - using var metrics = new PerformanceMetrics(); - for (var i = 1; i <= 100; i++) - metrics.RecordOperation("Op", TimeSpan.FromMilliseconds(i)); - - var stats = metrics.GetStatistics()["Op"]; - stats.Percentile95Milliseconds.ShouldBe(95); - } - - /// - /// Confirms that the rolling buffer keeps the most recent operation durations for percentile calculations. - /// - [Fact] - public void RollingBuffer_EvictsOldEntries() - { - var opMetrics = new OperationMetrics(); - for (var i = 0; i < 1100; i++) - opMetrics.Record(TimeSpan.FromMilliseconds(i), true); - - var stats = opMetrics.GetStatistics(); - stats.TotalCount.ShouldBe(1100); - // P95 should be from the last 1000 entries (100-1099) - stats.Percentile95Milliseconds.ShouldBeGreaterThan(1000); - } - - /// - /// Confirms that a timing scope records an operation when disposed. - /// - [Fact] - public void BeginOperation_TimingScopeRecordsOnDispose() - { - using var metrics = new PerformanceMetrics(); - - using (var scope = metrics.BeginOperation("Test")) - { - // Simulate some work - Thread.Sleep(5); - } - - var stats = metrics.GetStatistics(); - stats.ShouldContainKey("Test"); - stats["Test"].TotalCount.ShouldBe(1); - stats["Test"].SuccessCount.ShouldBe(1); - stats["Test"].AverageMilliseconds.ShouldBeGreaterThan(0); - } - - /// - /// Confirms that a timing scope can mark an operation as failed before disposal. - /// - [Fact] - public void BeginOperation_SetSuccessFalse() - { - using var metrics = new PerformanceMetrics(); - - using (var scope = metrics.BeginOperation("Test")) - { - scope.SetSuccess(false); - } - - var stats = metrics.GetStatistics()["Test"]; - stats.TotalCount.ShouldBe(1); - stats.SuccessCount.ShouldBe(0); - } - - /// - /// Confirms that looking up an unknown operation returns no metrics bucket. - /// - [Fact] - public void GetMetrics_UnknownOperation_ReturnsNull() - { - using var metrics = new PerformanceMetrics(); - metrics.GetMetrics("NonExistent").ShouldBeNull(); - } - - /// - /// Confirms that operation names are tracked without case sensitivity. - /// - [Fact] - public void OperationNames_AreCaseInsensitive() - { - using var metrics = new PerformanceMetrics(); - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10)); - metrics.RecordOperation("read", TimeSpan.FromMilliseconds(20)); - - var stats = metrics.GetStatistics(); - stats.Count.ShouldBe(1); - stats["READ"].TotalCount.ShouldBe(2); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/GalaxyRuntimeProbeManagerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/GalaxyRuntimeProbeManagerTests.cs deleted file mode 100644 index a439f51..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/GalaxyRuntimeProbeManagerTests.cs +++ /dev/null @@ -1,547 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Exhaustive coverage of the runtime host probe manager: state machine, sync diff, - /// transport gating, unknown-resolution timeout, and transition callbacks. - /// - public class GalaxyRuntimeProbeManagerTests - { - // ---------- State transitions ---------- - - [Fact] - public async Task Sync_WithMixedRuntimeHosts_AddsProbesAndEntriesInUnknown() - { - var (client, clock) = (new FakeMxAccessClient(), new Clock()); - var (stopSpy, runSpy) = (new List(), new List()); - using var sut = Sut(client, 15, stopSpy, runSpy, clock); - - await sut.SyncAsync(new[] - { - Platform(10, "DevPlatform"), - Engine(20, "DevAppEngine"), - UserObject(30, "TestMachine_001") - }); - - sut.ActiveProbeCount.ShouldBe(2); - var snap = sut.GetSnapshot(); - snap.Select(s => s.ObjectName).ShouldBe(new[] { "DevAppEngine", "DevPlatform" }); - snap.All(s => s.State == GalaxyRuntimeState.Unknown).ShouldBeTrue(); - snap.First(s => s.ObjectName == "DevPlatform").Kind.ShouldBe("$WinPlatform"); - snap.First(s => s.ObjectName == "DevAppEngine").Kind.ShouldBe("$AppEngine"); - stopSpy.ShouldBeEmpty(); - runSpy.ShouldBeEmpty(); - } - - [Fact] - public async Task HandleProbeUpdate_FirstGoodCallback_TransitionsUnknownToRunning() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - var handled = sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - handled.ShouldBeTrue(); - var entry = sut.GetSnapshot().Single(); - entry.State.ShouldBe(GalaxyRuntimeState.Running); - entry.LastScanState.ShouldBe(true); - entry.GoodUpdateCount.ShouldBe(1); - entry.FailureCount.ShouldBe(0); - entry.LastError.ShouldBeNull(); - // Unknown → Running is startup initialization, not a recovery — the onHostRunning - // callback is reserved for Stopped → Running transitions so the node manager does - // not wipe Bad status set by a concurrently-stopping sibling host on the same variable. - runSpy.ShouldBeEmpty(); - stopSpy.ShouldBeEmpty(); - } - - [Fact] - public async Task HandleProbeUpdate_ScanStateFalse_TransitionsRunningToStopped() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - stopSpy.Clear(); runSpy.Clear(); - - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false)); - - var entry = sut.GetSnapshot().Single(); - entry.State.ShouldBe(GalaxyRuntimeState.Stopped); - entry.LastScanState.ShouldBe(false); - entry.FailureCount.ShouldBe(1); - entry.LastError!.ShouldContain("OffScan"); - stopSpy.ShouldBe(new[] { 20 }); - runSpy.ShouldBeEmpty(); - } - - [Fact] - public async Task HandleProbeUpdate_BadQualityCallback_TransitionsRunningToStopped() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Platform(10, "DevPlatform") }); - sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Good(true)); - stopSpy.Clear(); - - sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Bad(Quality.BadCommFailure)); - - var entry = sut.GetSnapshot().Single(); - entry.State.ShouldBe(GalaxyRuntimeState.Stopped); - entry.LastError!.ShouldContain("bad quality"); - stopSpy.ShouldBe(new[] { 10 }); - } - - [Fact] - public async Task HandleProbeUpdate_RecoveryAfterStopped_FiresRunningCallback() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false)); - runSpy.Clear(); stopSpy.Clear(); - - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - runSpy.ShouldBe(new[] { 20 }); - stopSpy.ShouldBeEmpty(); - var entry = sut.GetSnapshot().Single(); - entry.State.ShouldBe(GalaxyRuntimeState.Running); - entry.LastError.ShouldBeNull(); - } - - [Fact] - public async Task HandleProbeUpdate_RepeatedRunning_DoesNotRefire() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - // Unknown → Running is silent; subsequent Running updates are idempotent. - runSpy.ShouldBeEmpty(); - sut.GetSnapshot().Single().GoodUpdateCount.ShouldBe(3); - } - - [Fact] - public async Task HandleProbeUpdate_NonProbeAddress_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - var handled = sut.HandleProbeUpdate("UnrelatedObject.Value", Vtq.Good(42)); - - handled.ShouldBeFalse(); - sut.GetSnapshot().Single().GoodUpdateCount.ShouldBe(0); - } - - // ---------- Unknown-resolution timeout ---------- - - [Fact] - public async Task Tick_UnknownBeyondTimeout_TransitionsToStopped() - { - var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) }; - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy, clock); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - // 16 seconds later — past the 15s timeout - clock.Now = clock.Now.AddSeconds(16); - sut.Tick(); - - var entry = sut.GetSnapshot().Single(); - entry.State.ShouldBe(GalaxyRuntimeState.Stopped); - entry.LastError!.ShouldContain("unknown-resolution"); - stopSpy.ShouldBe(new[] { 20 }); - } - - [Fact] - public async Task Tick_UnknownWithinTimeout_DoesNotTransition() - { - var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) }; - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy, clock); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - clock.Now = clock.Now.AddSeconds(10); - sut.Tick(); - - sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Unknown); - stopSpy.ShouldBeEmpty(); - } - - [Fact] - public async Task Tick_RunningHostWithOldCallback_DoesNotTransition() - { - // Critical on-change-semantic test: a stably Running host may go minutes or hours - // without a callback. Tick must NOT time it out on a starvation basis. - var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) }; - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy, clock); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - clock.Now = clock.Now.AddHours(2); // 2 hours of silence - sut.Tick(); - - sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running); - } - - // ---------- Transport gating ---------- - - [Fact] - public async Task GetSnapshot_WhenTransportDisconnected_ForcesEveryEntryToUnknown() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] - { - Platform(10, "DevPlatform"), - Engine(20, "DevAppEngine") - }); - sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false)); - - client.State = ConnectionState.Disconnected; - - sut.GetSnapshot().All(s => s.State == GalaxyRuntimeState.Unknown).ShouldBeTrue(); - - // Underlying state is preserved — restore transport and snapshot reflects reality again. - client.State = ConnectionState.Connected; - var restored = sut.GetSnapshot(); - restored.First(s => s.ObjectName == "DevPlatform").State.ShouldBe(GalaxyRuntimeState.Running); - restored.First(s => s.ObjectName == "DevAppEngine").State.ShouldBe(GalaxyRuntimeState.Stopped); - } - - // ---------- Sync diff ---------- - - [Fact] - public async Task Sync_WithHostRemoved_UnadvisesProbeAndDropsEntry() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] - { - Platform(10, "DevPlatform"), - Engine(20, "DevAppEngine") - }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - await sut.SyncAsync(new[] { Platform(10, "DevPlatform") }); - - sut.ActiveProbeCount.ShouldBe(1); - sut.GetSnapshot().Single().ObjectName.ShouldBe("DevPlatform"); - } - - [Fact] - public async Task Sync_WithUnchangedHostSet_PreservesExistingState() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - runSpy.Clear(); - - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running); - runSpy.ShouldBeEmpty(); // no re-fire on no-op resync - } - - [Fact] - public async Task Sync_FiltersNonRuntimeCategories() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] - { - Platform(10, "DevPlatform"), - UserObject(30, "TestMachine_001"), - AreaObject(40, "DEV"), - Engine(20, "DevAppEngine"), - UserObject(31, "TestMachine_002") - }); - - sut.ActiveProbeCount.ShouldBe(2); // only the platform + the engine - } - - // ---------- Dispose ---------- - - [Fact] - public async Task Dispose_UnadvisesEveryActiveProbe() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] - { - Platform(10, "DevPlatform"), - Engine(20, "DevAppEngine") - }); - - sut.Dispose(); - - sut.ActiveProbeCount.ShouldBe(0); - // After dispose, a Sync is a no-op. - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.ActiveProbeCount.ShouldBe(0); - } - - [Fact] - public void Dispose_OnFreshManager_NoOp() - { - var client = new FakeMxAccessClient(); - var sut = Sut(client, 15, new List(), new List()); - - Should.NotThrow(() => sut.Dispose()); - Should.NotThrow(() => sut.Dispose()); - } - - [Fact] - public async Task HandleProbeUpdate_AfterDispose_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.Dispose(); - - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)).ShouldBeFalse(); - } - - // ---------- IsHostStopped (Read-path short-circuit support) ---------- - - [Fact] - public async Task IsHostStopped_UnknownHost_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - // Never delivered a callback — state is Unknown. Read-path should NOT short-circuit - // on Unknown because the host might come online any moment. - sut.IsHostStopped(20).ShouldBeFalse(); - } - - [Fact] - public async Task IsHostStopped_RunningHost_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - sut.IsHostStopped(20).ShouldBeFalse(); - } - - [Fact] - public async Task IsHostStopped_StoppedHost_ReturnsTrue() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false)); - - sut.IsHostStopped(20).ShouldBeTrue(); - } - - [Fact] - public async Task IsHostStopped_AfterRecovery_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false)); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - sut.IsHostStopped(20).ShouldBeFalse(); - } - - [Fact] - public async Task IsHostStopped_UnknownGobjectId_ReturnsFalse() - { - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - // Not a probed host — defensive false rather than throwing. - sut.IsHostStopped(99999).ShouldBeFalse(); - } - - [Fact] - public async Task IsHostStopped_TransportDisconnected_UsesUnderlyingState() - { - // Critical contract: IsHostStopped is intended for the Read-path short-circuit and - // uses the underlying state directly, NOT the GetSnapshot transport-gated rewrite. - // When the transport is disconnected, MxAccess reads will fail via the normal error - // path; we don't want IsHostStopped to double-flag the Read as stopped if the host - // itself was actually Running before the transport dropped. - var (client, stopSpy, runSpy) = NewSpyHarness(); - using var sut = Sut(client, 15, stopSpy, runSpy); - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)); - - client.State = ConnectionState.Disconnected; - - // Running state preserved — short-circuit does NOT fire during transport outages. - sut.IsHostStopped(20).ShouldBeFalse(); - } - - // ---------- Subscribe failure rollback (stability review 2026-04-13 Finding 1) ---------- - - [Fact] - public async Task Sync_SubscribeThrows_DoesNotLeavePhantomEntry() - { - var client = new FakeMxAccessClient - { - SubscribeException = new InvalidOperationException("advise failed") - }; - var (stopSpy, runSpy) = (new List(), new List()); - using var sut = Sut(client, 15, stopSpy, runSpy); - - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - // A failed SubscribeAsync must not leave a phantom entry that Tick() can later - // transition from Unknown to Stopped. - sut.ActiveProbeCount.ShouldBe(0); - sut.GetSnapshot().ShouldBeEmpty(); - sut.IsHostStopped(20).ShouldBeFalse(); - } - - [Fact] - public async Task Sync_SubscribeThrows_TickDoesNotFireStopCallback() - { - var client = new FakeMxAccessClient - { - SubscribeException = new InvalidOperationException("advise failed") - }; - var clock = new Clock(); - var (stopSpy, runSpy) = (new List(), new List()); - using var sut = Sut(client, 15, stopSpy, runSpy, clock); - - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - // Advance past the unknown timeout — if the rollback were incomplete, Tick() would - // transition the phantom entry to Stopped and fan out a false host-down signal. - clock.Now = clock.Now.AddSeconds(30); - sut.Tick(); - - stopSpy.ShouldBeEmpty(); - runSpy.ShouldBeEmpty(); - sut.ActiveProbeCount.ShouldBe(0); - } - - [Fact] - public async Task Sync_SubscribeSucceedsAfterRetry_AppearsInSnapshot() - { - // After a failed subscribe rolls back cleanly, a subsequent successful SyncAsync - // against the same host must behave normally. - var client = new FakeMxAccessClient - { - SubscribeException = new InvalidOperationException("first attempt fails") - }; - var (stopSpy, runSpy) = (new List(), new List()); - using var sut = Sut(client, 15, stopSpy, runSpy); - - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - sut.ActiveProbeCount.ShouldBe(0); - - // Clear the fault and resync — the host must now appear with Unknown state. - client.SubscribeException = null; - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - sut.ActiveProbeCount.ShouldBe(1); - sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Unknown); - } - - // ---------- Callback exception safety ---------- - - [Fact] - public async Task TransitionCallback_ThrowsException_DoesNotCorruptState() - { - var client = new FakeMxAccessClient(); - Action badCallback = _ => throw new InvalidOperationException("boom"); - using var sut = new GalaxyRuntimeProbeManager(client, 15, badCallback, badCallback); - - await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") }); - - Should.NotThrow(() => sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true))); - sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running); - } - - // ---------- Helpers ---------- - - private static GalaxyRuntimeProbeManager Sut( - FakeMxAccessClient client, - int timeoutSeconds, - List stopSpy, - List runSpy, - Clock? clock = null) - { - clock ??= new Clock(); - return new GalaxyRuntimeProbeManager( - client, timeoutSeconds, - stopSpy.Add, - runSpy.Add, - () => clock.Now); - } - - private static (FakeMxAccessClient client, List stopSpy, List runSpy) NewSpyHarness() - { - return (new FakeMxAccessClient(), new List(), new List()); - } - - private static GalaxyObjectInfo Platform(int id, string name) => new() - { - GobjectId = id, - TagName = name, - CategoryId = 1, - HostedByGobjectId = 0 - }; - - private static GalaxyObjectInfo Engine(int id, string name) => new() - { - GobjectId = id, - TagName = name, - CategoryId = 3, - HostedByGobjectId = 10 - }; - - private static GalaxyObjectInfo UserObject(int id, string name) => new() - { - GobjectId = id, - TagName = name, - CategoryId = 10, - HostedByGobjectId = 20 - }; - - private static GalaxyObjectInfo AreaObject(int id, string name) => new() - { - GobjectId = id, - TagName = name, - CategoryId = 13, - IsArea = true, - HostedByGobjectId = 20 - }; - - private sealed class Clock - { - public DateTime Now { get; set; } = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientConnectionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientConnectionTests.cs deleted file mode 100644 index 5c8abb1..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientConnectionTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Verifies MXAccess client connection lifecycle behavior, including transitions, registration, and reconnect - /// handling. - /// - public class MxAccessClientConnectionTests : IDisposable - { - private readonly MxAccessClient _client; - private readonly PerformanceMetrics _metrics; - private readonly FakeMxProxy _proxy; - private readonly List<(ConnectionState Previous, ConnectionState Current)> _stateChanges = new(); - private readonly StaComThread _staThread; - - /// - /// Initializes the connection test fixture with a fake runtime proxy and state-change recorder. - /// - public MxAccessClientConnectionTests() - { - _staThread = new StaComThread(); - _staThread.Start(); - _proxy = new FakeMxProxy(); - _metrics = new PerformanceMetrics(); - var config = new MxAccessConfiguration(); - _client = new MxAccessClient(_staThread, _proxy, config, _metrics); - _client.ConnectionStateChanged += (_, e) => _stateChanges.Add((e.PreviousState, e.CurrentState)); - } - - /// - /// Disposes the connection test fixture and its supporting resources. - /// - public void Dispose() - { - _client.Dispose(); - _staThread.Dispose(); - _metrics.Dispose(); - } - - /// - /// Confirms that a newly created MXAccess client starts in the disconnected state. - /// - [Fact] - public void InitialState_IsDisconnected() - { - _client.State.ShouldBe(ConnectionState.Disconnected); - } - - /// - /// Confirms that connecting drives the expected disconnected-to-connecting-to-connected transitions. - /// - [Fact] - public async Task Connect_TransitionsToConnected() - { - await _client.ConnectAsync(); - - _client.State.ShouldBe(ConnectionState.Connected); - _stateChanges.ShouldContain(s => - s.Previous == ConnectionState.Disconnected && s.Current == ConnectionState.Connecting); - _stateChanges.ShouldContain(s => - s.Previous == ConnectionState.Connecting && s.Current == ConnectionState.Connected); - } - - /// - /// Confirms that a successful connect registers exactly once with the runtime proxy. - /// - [Fact] - public async Task Connect_RegistersCalled() - { - await _client.ConnectAsync(); - _proxy.RegisterCallCount.ShouldBe(1); - } - - /// - /// Confirms that disconnecting drives the expected shutdown transitions back to disconnected. - /// - [Fact] - public async Task Disconnect_TransitionsToDisconnected() - { - await _client.ConnectAsync(); - await _client.DisconnectAsync(); - - _client.State.ShouldBe(ConnectionState.Disconnected); - _stateChanges.ShouldContain(s => s.Current == ConnectionState.Disconnecting); - _stateChanges.ShouldContain(s => s.Current == ConnectionState.Disconnected); - } - - /// - /// Confirms that disconnecting unregisters the runtime proxy session. - /// - [Fact] - public async Task Disconnect_UnregistersCalled() - { - await _client.ConnectAsync(); - await _client.DisconnectAsync(); - _proxy.UnregisterCallCount.ShouldBe(1); - } - - /// - /// Confirms that registration failures move the client into the error state. - /// - [Fact] - public async Task ConnectFails_TransitionsToError() - { - _proxy.ShouldFailRegister = true; - - await Should.ThrowAsync(_client.ConnectAsync()); - _client.State.ShouldBe(ConnectionState.Error); - } - - /// - /// Confirms that repeated connect calls do not perform duplicate runtime registrations. - /// - [Fact] - public async Task DoubleConnect_NoOp() - { - await _client.ConnectAsync(); - await _client.ConnectAsync(); // Should be no-op - _proxy.RegisterCallCount.ShouldBe(1); - } - - /// - /// Confirms that reconnect increments the reconnect counter and restores the connected state. - /// - [Fact] - public async Task Reconnect_IncrementsCount() - { - await _client.ConnectAsync(); - _client.ReconnectCount.ShouldBe(0); - - await _client.ReconnectAsync(); - _client.ReconnectCount.ShouldBe(1); - _client.State.ShouldBe(ConnectionState.Connected); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientMonitorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientMonitorTests.cs deleted file mode 100644 index 975638a..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientMonitorTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Verifies the background connectivity monitor used to reconnect the MXAccess bridge after faults or stale probes. - /// - public class MxAccessClientMonitorTests : IDisposable - { - private readonly PerformanceMetrics _metrics; - private readonly FakeMxProxy _proxy; - private readonly StaComThread _staThread; - - /// - /// Initializes the monitor test fixture with a shared STA thread, fake proxy, and metrics collector. - /// - public MxAccessClientMonitorTests() - { - _staThread = new StaComThread(); - _staThread.Start(); - _proxy = new FakeMxProxy(); - _metrics = new PerformanceMetrics(); - } - - /// - /// Disposes the monitor test fixture resources. - /// - public void Dispose() - { - _staThread.Dispose(); - _metrics.Dispose(); - } - - /// - /// Confirms that the monitor reconnects the client after an observed disconnect. - /// - [Fact] - public async Task Monitor_ReconnectsOnDisconnect() - { - var config = new MxAccessConfiguration - { - MonitorIntervalSeconds = 1, - AutoReconnect = true - }; - var client = new MxAccessClient(_staThread, _proxy, config, _metrics); - - await client.ConnectAsync(); - await client.DisconnectAsync(); - - client.StartMonitor(); - - // Wait for monitor to detect disconnect and reconnect - await Task.Delay(2500); - - client.StopMonitor(); - client.State.ShouldBe(ConnectionState.Connected); - client.ReconnectCount.ShouldBeGreaterThan(0); - client.Dispose(); - } - - /// - /// Confirms that the monitor can be started and stopped without throwing. - /// - [Fact] - public async Task Monitor_StopsOnCancel() - { - var config = new MxAccessConfiguration { MonitorIntervalSeconds = 1 }; - var client = new MxAccessClient(_staThread, _proxy, config, _metrics); - - await client.ConnectAsync(); - client.StartMonitor(); - client.StopMonitor(); - - // Should not throw - await Task.Delay(200); - client.Dispose(); - } - - /// - /// Confirms that a stale probe tag triggers a reconnect when monitoring is enabled. - /// - [Fact] - public async Task Monitor_ProbeStale_ForcesReconnect() - { - var config = new MxAccessConfiguration - { - ProbeTag = "TestProbe", - ProbeStaleThresholdSeconds = 2, - MonitorIntervalSeconds = 1, - AutoReconnect = true - }; - var client = new MxAccessClient(_staThread, _proxy, config, _metrics); - - await client.ConnectAsync(); - client.StartMonitor(); - - // Wait long enough for probe to go stale (threshold=2s, monitor interval=1s) - // No data changes simulated → probe becomes stale → reconnect triggered - await Task.Delay(4000); - - client.StopMonitor(); - client.ReconnectCount.ShouldBeGreaterThan(0); - client.Dispose(); - } - - /// - /// Confirms that fresh probe updates prevent unnecessary reconnects. - /// - [Fact] - public async Task Monitor_ProbeDataChange_PreventsStaleReconnect() - { - var config = new MxAccessConfiguration - { - ProbeTag = "TestProbe", - ProbeStaleThresholdSeconds = 5, - MonitorIntervalSeconds = 1, - AutoReconnect = true - }; - var client = new MxAccessClient(_staThread, _proxy, config, _metrics); - - await client.ConnectAsync(); - client.StartMonitor(); - - // Continuously simulate probe data changes to keep it fresh - // Stale threshold (5s) is well above the delay (500ms) to avoid timing flakes - for (var i = 0; i < 8; i++) - { - await Task.Delay(500); - _proxy.SimulateDataChangeByAddress("TestProbe", i); - } - - client.StopMonitor(); - // Probe was kept fresh → no reconnect should have happened - client.ReconnectCount.ShouldBe(0); - client.State.ShouldBe(ConnectionState.Connected); - client.Dispose(); - } - - /// - /// Confirms that enabling the monitor without a probe tag does not trigger false reconnects. - /// - [Fact] - public async Task Monitor_NoProbeConfigured_NoFalseReconnect() - { - var config = new MxAccessConfiguration - { - ProbeTag = null, // No probe - MonitorIntervalSeconds = 1, - AutoReconnect = true - }; - var client = new MxAccessClient(_staThread, _proxy, config, _metrics); - - await client.ConnectAsync(); - client.StartMonitor(); - - // Wait several monitor cycles — should stay connected with no reconnects - await Task.Delay(3000); - - client.StopMonitor(); - client.State.ShouldBe(ConnectionState.Connected); - client.ReconnectCount.ShouldBe(0); - client.Dispose(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientReadWriteTests.cs deleted file mode 100644 index fa91cf0..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientReadWriteTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Verifies MXAccess client read and write behavior against the fake runtime proxy used by the bridge. - /// - public class MxAccessClientReadWriteTests : IDisposable - { - private readonly MxAccessClient _client; - private readonly PerformanceMetrics _metrics; - private readonly FakeMxProxy _proxy; - private readonly StaComThread _staThread; - - /// - /// Initializes the COM-threaded MXAccess test fixture with a fake runtime proxy and metrics collector. - /// - public MxAccessClientReadWriteTests() - { - _staThread = new StaComThread(); - _staThread.Start(); - _proxy = new FakeMxProxy(); - _metrics = new PerformanceMetrics(); - var config = new MxAccessConfiguration { ReadTimeoutSeconds = 2, WriteTimeoutSeconds = 2 }; - _client = new MxAccessClient(_staThread, _proxy, config, _metrics); - } - - /// - /// Disposes the MXAccess client fixture and its supporting STA thread and metrics collector. - /// - public void Dispose() - { - _client.Dispose(); - _staThread.Dispose(); - _metrics.Dispose(); - } - - /// - /// Confirms that reads fail with bad-not-connected quality when the runtime session is offline. - /// - [Fact] - public async Task Read_NotConnected_ReturnsBad() - { - var result = await _client.ReadAsync("Tag.Attr"); - result.Quality.ShouldBe(Quality.BadNotConnected); - } - - /// - /// Confirms that a runtime data-change callback completes a pending read with the published value. - /// - [Fact] - public async Task Read_ReturnsValueOnDataChange() - { - await _client.ConnectAsync(); - - // Start read in background - var readTask = _client.ReadAsync("TestTag.Attr"); - - // Give it a moment to set up subscription, then simulate data change - await Task.Delay(50); - _proxy.SimulateDataChangeByAddress("TestTag.Attr", 42); - - var result = await readTask; - result.Value.ShouldBe(42); - result.Quality.ShouldBe(Quality.Good); - } - - /// - /// Confirms that reads time out with bad communication-failure quality when the runtime never responds. - /// - [Fact] - public async Task Read_Timeout_ReturnsBadCommFailure() - { - await _client.ConnectAsync(); - - // No data change simulated, so it will timeout - var result = await _client.ReadAsync("TestTag.Attr"); - result.Quality.ShouldBe(Quality.BadCommFailure); - } - - /// - /// Confirms that timed-out reads are recorded as failed read operations in the metrics collector. - /// - [Fact] - public async Task Read_Timeout_RecordsFailedMetrics() - { - await _client.ConnectAsync(); - - var result = await _client.ReadAsync("TestTag.Attr"); - result.Quality.ShouldBe(Quality.BadCommFailure); - - var stats = _metrics.GetStatistics(); - stats.ShouldContainKey("Read"); - stats["Read"].TotalCount.ShouldBe(1); - stats["Read"].SuccessCount.ShouldBe(0); - } - - /// - /// Confirms that writes are rejected when the runtime session is not connected. - /// - [Fact] - public async Task Write_NotConnected_ReturnsFalse() - { - var result = await _client.WriteAsync("Tag.Attr", 42); - result.ShouldBe(false); - } - - /// - /// Confirms that successful runtime write acknowledgments return success and record the written payload. - /// - [Fact] - public async Task Write_Success_ReturnsTrue() - { - await _client.ConnectAsync(); - _proxy.WriteCompleteStatus = 0; - - var result = await _client.WriteAsync("TestTag.Attr", 42); - result.ShouldBe(true); - _proxy.WrittenValues.ShouldContain(w => w.Address == "TestTag.Attr" && (int)w.Value == 42); - } - - /// - /// Confirms that MXAccess error codes on write completion are surfaced as failed writes. - /// - [Fact] - public async Task Write_ErrorCode_ReturnsFalse() - { - await _client.ConnectAsync(); - _proxy.WriteCompleteStatus = 1012; // Wrong data type - - var result = await _client.WriteAsync("TestTag.Attr", "bad_value"); - result.ShouldBe(false); - } - - /// - /// Confirms that write timeouts are recorded as failed write operations in the metrics collector. - /// - [Fact] - public async Task Write_Timeout_ReturnsFalse_AndRecordsFailedMetrics() - { - await _client.ConnectAsync(); - _proxy.SkipWriteCompleteCallback = true; - - var result = await _client.WriteAsync("TestTag.Attr", 42); - result.ShouldBe(false); - - var stats = _metrics.GetStatistics(); - stats.ShouldContainKey("Write"); - stats["Write"].TotalCount.ShouldBe(1); - stats["Write"].SuccessCount.ShouldBe(0); - } - - /// - /// Confirms that successful reads contribute a read entry to the metrics collector. - /// - [Fact] - public async Task Read_RecordsMetrics() - { - await _client.ConnectAsync(); - - var readTask = _client.ReadAsync("TestTag.Attr"); - await Task.Delay(50); - _proxy.SimulateDataChangeByAddress("TestTag.Attr", 1); - await readTask; - - var stats = _metrics.GetStatistics(); - stats.ShouldContainKey("Read"); - stats["Read"].TotalCount.ShouldBe(1); - } - - /// - /// Confirms that writes contribute a write entry to the metrics collector. - /// - [Fact] - public async Task Write_RecordsMetrics() - { - await _client.ConnectAsync(); - await _client.WriteAsync("TestTag.Attr", 42); - - var stats = _metrics.GetStatistics(); - stats.ShouldContainKey("Write"); - stats["Write"].TotalCount.ShouldBe(1); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientSubscriptionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientSubscriptionTests.cs deleted file mode 100644 index f33b91a..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/MxAccessClientSubscriptionTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Verifies how the MXAccess client manages persistent subscriptions, reconnect replay, and probe-tag behavior. - /// - public class MxAccessClientSubscriptionTests : IDisposable - { - private readonly MxAccessClient _client; - private readonly PerformanceMetrics _metrics; - private readonly FakeMxProxy _proxy; - private readonly StaComThread _staThread; - - /// - /// Initializes the subscription test fixture with a fake runtime proxy and STA thread. - /// - public MxAccessClientSubscriptionTests() - { - _staThread = new StaComThread(); - _staThread.Start(); - _proxy = new FakeMxProxy(); - _metrics = new PerformanceMetrics(); - _client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics); - } - - /// - /// Disposes the subscription test fixture and its supporting resources. - /// - public void Dispose() - { - _client.Dispose(); - _staThread.Dispose(); - _metrics.Dispose(); - } - - /// - /// Confirms that subscribing creates a runtime item, advises it, and increments the active subscription count. - /// - [Fact] - public async Task Subscribe_CreatesItemAndAdvises() - { - await _client.ConnectAsync(); - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - - _proxy.Items.Count.ShouldBeGreaterThan(0); - _proxy.AdvisedItems.Count.ShouldBeGreaterThan(0); - _client.ActiveSubscriptionCount.ShouldBe(1); - } - - /// - /// Confirms that subscribing to the same address twice reuses the existing runtime item. - /// - [Fact] - public async Task Subscribe_SameAddressTwice_ReusesExistingRuntimeItem() - { - await _client.ConnectAsync(); - - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - - _client.ActiveSubscriptionCount.ShouldBe(1); - _proxy.Items.Values.Count(v => v == "TestTag.Attr").ShouldBe(1); - - await _client.UnsubscribeAsync("TestTag.Attr"); - - _proxy.Items.Values.ShouldNotContain("TestTag.Attr"); - } - - /// - /// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored. - /// - [Fact] - public async Task Unsubscribe_RemovesItemAndUnadvises() - { - await _client.ConnectAsync(); - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - await _client.UnsubscribeAsync("TestTag.Attr"); - - _client.ActiveSubscriptionCount.ShouldBe(0); - } - - /// - /// Confirms that runtime data changes are delivered to the per-subscription callback. - /// - [Fact] - public async Task OnDataChange_InvokesCallback() - { - await _client.ConnectAsync(); - - Vtq? received = null; - await _client.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq); - - _proxy.SimulateDataChangeByAddress("TestTag.Attr", 42); - - received.ShouldNotBeNull(); - received.Value.Value.ShouldBe(42); - received.Value.Quality.ShouldBe(Quality.Good); - } - - /// - /// Confirms that runtime data changes are also delivered to the client's global tag-change event. - /// - [Fact] - public async Task OnDataChange_InvokesGlobalHandler() - { - await _client.ConnectAsync(); - - string? globalAddr = null; - _client.OnTagValueChanged += (addr, vtq) => globalAddr = addr; - - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - _proxy.SimulateDataChangeByAddress("TestTag.Attr", "hello"); - - globalAddr.ShouldBe("TestTag.Attr"); - } - - /// - /// Confirms that stored subscriptions are replayed after reconnect so live updates resume automatically. - /// - [Fact] - public async Task StoredSubscriptions_ReplayedAfterReconnect() - { - await _client.ConnectAsync(); - var callbackInvoked = false; - await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true); - - // Reconnect - await _client.ReconnectAsync(); - - // After reconnect, subscription should be replayed - _client.ActiveSubscriptionCount.ShouldBe(1); - - // Simulate data change on the re-subscribed item - _proxy.SimulateDataChangeByAddress("TestTag.Attr", "value"); - callbackInvoked.ShouldBe(true); - } - - /// - /// Confirms that one-shot reads do not remove persistent subscriptions when the client reconnects. - /// - [Fact] - public async Task OneShotRead_DoesNotRemovePersistentSubscription_OnReconnect() - { - await _client.ConnectAsync(); - var callbackInvoked = false; - await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true); - - var readTask = _client.ReadAsync("TestTag.Attr"); - await Task.Delay(50); - _proxy.SimulateDataChangeByAddress("TestTag.Attr", 42); - (await readTask).Value.ShouldBe(42); - callbackInvoked = false; - - await _client.ReconnectAsync(); - - _proxy.SimulateDataChangeByAddress("TestTag.Attr", "after_reconnect"); - callbackInvoked.ShouldBe(true); - _client.ActiveSubscriptionCount.ShouldBe(1); - } - - /// - /// Confirms that transient writes do not prevent later removal of a persistent subscription. - /// - [Fact] - public async Task OneShotWrite_DoesNotBreakPersistentUnsubscribe() - { - await _client.ConnectAsync(); - await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); - _proxy.Items.Values.ShouldContain("TestTag.Attr"); - - var writeResult = await _client.WriteAsync("TestTag.Attr", 7); - writeResult.ShouldBe(true); - - await _client.UnsubscribeAsync("TestTag.Attr"); - - _client.ActiveSubscriptionCount.ShouldBe(0); - _proxy.Items.Values.ShouldNotContain("TestTag.Attr"); - } - - /// - /// Confirms that the configured probe tag is subscribed during connect so connectivity monitoring can start - /// immediately. - /// - [Fact] - public async Task ProbeTag_SubscribedOnConnect() - { - var proxy = new FakeMxProxy(); - var config = new MxAccessConfiguration { ProbeTag = "TestProbe" }; - var client = new MxAccessClient(_staThread, proxy, config, _metrics); - - await client.ConnectAsync(); - - // Probe tag should be subscribed (present in proxy items) - proxy.Items.Values.ShouldContain("TestProbe"); - client.Dispose(); - } - - /// - /// Confirms that the probe tag cannot be unsubscribed accidentally because it is reserved for connection monitoring. - /// - [Fact] - public async Task ProbeTag_ProtectedFromUnsubscribe() - { - var proxy = new FakeMxProxy(); - var config = new MxAccessConfiguration { ProbeTag = "TestProbe" }; - var client = new MxAccessClient(_staThread, proxy, config, _metrics); - - await client.ConnectAsync(); - proxy.Items.Values.ShouldContain("TestProbe"); - - // Attempt to unsubscribe the probe tag — should be protected - await client.UnsubscribeAsync("TestProbe"); - - // Probe should still be in the proxy items (not removed) - proxy.Items.Values.ShouldContain("TestProbe"); - client.Dispose(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/StaComThreadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/StaComThreadTests.cs deleted file mode 100644 index 6089cdb..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/MxAccess/StaComThreadTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.MxAccess; - -namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess -{ - /// - /// Verifies the single-threaded apartment worker used to marshal COM calls for the MXAccess bridge. - /// - public class StaComThreadTests : IDisposable - { - [DllImport("user32.dll")] - private static extern void PostQuitMessage(int nExitCode); - - private readonly StaComThread _thread; - - /// - /// Starts a fresh STA thread instance for each test. - /// - public StaComThreadTests() - { - _thread = new StaComThread(); - _thread.Start(); - } - - /// - /// Disposes the STA thread after each test. - /// - public void Dispose() - { - _thread.Dispose(); - } - - /// - /// Confirms that queued work runs on a thread configured for STA apartment state. - /// - [Fact] - public async Task RunAsync_ExecutesOnStaThread() - { - var apartmentState = await _thread.RunAsync(() => Thread.CurrentThread.GetApartmentState()); - apartmentState.ShouldBe(ApartmentState.STA); - } - - /// - /// Confirms that action delegates run to completion on the STA thread. - /// - [Fact] - public async Task RunAsync_Action_Completes() - { - var executed = false; - await _thread.RunAsync(() => executed = true); - executed.ShouldBe(true); - } - - /// - /// Confirms that function delegates can return results from the STA thread. - /// - [Fact] - public async Task RunAsync_Func_ReturnsResult() - { - var result = await _thread.RunAsync(() => 42); - result.ShouldBe(42); - } - - /// - /// Confirms that exceptions thrown on the STA thread propagate back to the caller. - /// - [Fact] - public async Task RunAsync_PropagatesException() - { - await Should.ThrowAsync( - _thread.RunAsync(() => throw new InvalidOperationException("test error"))); - } - - /// - /// Confirms that disposing the STA thread stops it from accepting additional work. - /// - [Fact] - public void Dispose_Stops_Thread() - { - var thread = new StaComThread(); - thread.Start(); - thread.IsRunning.ShouldBe(true); - thread.Dispose(); - // After dispose, should not accept new work - Should.Throw(() => thread.RunAsync(() => { }).GetAwaiter().GetResult()); - } - - /// - /// Confirms that multiple queued work items all execute successfully on the STA thread. - /// - [Fact] - public async Task MultipleWorkItems_ExecuteInOrder() - { - var results = new ConcurrentBag(); - await Task.WhenAll( - _thread.RunAsync(() => results.Add(1)), - _thread.RunAsync(() => results.Add(2)), - _thread.RunAsync(() => results.Add(3))); - - results.Count.ShouldBe(3); - } - - /// - /// Confirms that after the message pump exits, subsequent RunAsync calls throw instead of hanging. - /// - [Fact] - public async Task RunAsync_AfterPumpExit_ThrowsInsteadOfHanging() - { - // Kill the pump from inside by posting WM_QUIT - await _thread.RunAsync(() => PostQuitMessage(0)); - await Task.Delay(100); // let pump exit - - _thread.IsRunning.ShouldBe(false); - Should.Throw(() => - _thread.RunAsync(() => { }).GetAwaiter().GetResult()); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/AddressSpaceDiffTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/AddressSpaceDiffTests.cs deleted file mode 100644 index 98020e4..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/AddressSpaceDiffTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Collections.Generic; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - public class AddressSpaceDiffTests - { - private static GalaxyObjectInfo Obj(int id, string tag, int parent = 0, bool isArea = false) - { - return new GalaxyObjectInfo - { - GobjectId = id, TagName = tag, BrowseName = tag, ContainedName = tag, ParentGobjectId = parent, - IsArea = isArea - }; - } - - private static GalaxyAttributeInfo Attr(int gobjectId, string name, string tagName = "Obj", int mxDataType = 5) - { - return new GalaxyAttributeInfo - { - GobjectId = gobjectId, AttributeName = name, FullTagReference = $"{tagName}.{name}", - MxDataType = mxDataType, TagName = tagName - }; - } - - /// - /// Verifies that identical Galaxy hierarchy and attribute snapshots produce no incremental rebuild work. - /// - [Fact] - public void NoChanges_ReturnsEmptySet() - { - var h = new List { Obj(1, "A") }; - var a = new List { Attr(1, "X") }; - - var changed = AddressSpaceDiff.FindChangedGobjectIds(h, a, h, a); - changed.ShouldBeEmpty(); - } - - /// - /// Verifies that newly deployed Galaxy objects are flagged for OPC UA subtree creation. - /// - [Fact] - public void AddedObject_Detected() - { - var oldH = new List { Obj(1, "A") }; - var newH = new List { Obj(1, "A"), Obj(2, "B") }; - var a = new List(); - - var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a); - changed.ShouldContain(2); - changed.ShouldNotContain(1); - } - - /// - /// Verifies that removed Galaxy objects are flagged so their OPC UA subtree can be torn down. - /// - [Fact] - public void RemovedObject_Detected() - { - var oldH = new List { Obj(1, "A"), Obj(2, "B") }; - var newH = new List { Obj(1, "A") }; - var a = new List(); - - var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a); - changed.ShouldContain(2); - changed.ShouldNotContain(1); - } - - /// - /// Verifies that browse-name changes are treated as address-space changes for the affected Galaxy object. - /// - [Fact] - public void ModifiedObject_BrowseNameChange_Detected() - { - var oldH = new List { Obj(1, "A") }; - var newH = new List - { new() { GobjectId = 1, TagName = "A", BrowseName = "A_Renamed", ContainedName = "A" } }; - var a = new List(); - - var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a); - changed.ShouldContain(1); - } - - /// - /// Verifies that parent changes are treated as subtree moves that require rebuilding the affected object. - /// - [Fact] - public void ModifiedObject_ParentChange_Detected() - { - var oldH = new List { Obj(1, "A"), Obj(2, "B", 1) }; - var newH = new List - { - Obj(1, "A"), - new() { GobjectId = 2, TagName = "B", BrowseName = "B", ContainedName = "B", ParentGobjectId = 0 } - }; - var a = new List(); - - var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a); - changed.ShouldContain(2); - } - - /// - /// Verifies that adding a Galaxy attribute marks the owning object for OPC UA variable rebuild. - /// - [Fact] - public void AttributeAdded_Detected() - { - var h = new List { Obj(1, "A") }; - var oldA = new List { Attr(1, "X") }; - var newA = new List { Attr(1, "X"), Attr(1, "Y") }; - - var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA); - changed.ShouldContain(1); - } - - /// - /// Verifies that removing a Galaxy attribute marks the owning object for OPC UA variable rebuild. - /// - [Fact] - public void AttributeRemoved_Detected() - { - var h = new List { Obj(1, "A") }; - var oldA = new List { Attr(1, "X"), Attr(1, "Y") }; - var newA = new List { Attr(1, "X") }; - - var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA); - changed.ShouldContain(1); - } - - /// - /// Verifies that changes to attribute field metadata such as MX data type trigger rebuild of the owning object. - /// - [Fact] - public void AttributeFieldChange_Detected() - { - var h = new List { Obj(1, "A") }; - var oldA = new List { Attr(1, "X", mxDataType: 5) }; - var newA = new List { Attr(1, "X", mxDataType: 2) }; - - var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA); - changed.ShouldContain(1); - } - - /// - /// Verifies that security-classification changes are treated as address-space changes for the owning attribute. - /// - [Fact] - public void AttributeSecurityChange_Detected() - { - var h = new List { Obj(1, "A") }; - var oldA = new List - { new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 1 } }; - var newA = new List - { new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 2 } }; - - var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA); - changed.ShouldContain(1); - } - - /// - /// Verifies that subtree expansion includes all descendants of a changed Galaxy object. - /// - [Fact] - public void ExpandToSubtrees_IncludesChildren() - { - var h = new List - { - Obj(1, "Root"), - Obj(2, "Child", 1), - Obj(3, "Grandchild", 2), - Obj(4, "Sibling", 1), - Obj(5, "Unrelated") - }; - - var changed = new HashSet { 1 }; - var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h); - - expanded.ShouldContain(1); - expanded.ShouldContain(2); - expanded.ShouldContain(3); - expanded.ShouldContain(4); - expanded.ShouldNotContain(5); - } - - /// - /// Verifies that subtree expansion does not introduce unrelated nodes when the changed object is already a leaf. - /// - [Fact] - public void ExpandToSubtrees_LeafNode_NoExpansion() - { - var h = new List - { - Obj(1, "Root"), - Obj(2, "Child", 1), - Obj(3, "Sibling", 1) - }; - - var changed = new HashSet { 2 }; - var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h); - - expanded.ShouldContain(2); - expanded.ShouldNotContain(1); - expanded.ShouldNotContain(3); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/DataValueConverterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/DataValueConverterTests.cs deleted file mode 100644 index ffed661..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/DataValueConverterTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - /// - /// Verifies how bridge VTQ values are translated to and from OPC UA data values for the published namespace. - /// - public class DataValueConverterTests - { - /// - /// Confirms that boolean runtime values are preserved when converted to OPC UA data values. - /// - [Fact] - public void FromVtq_Boolean() - { - var vtq = Vtq.Good(true); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(true); - StatusCode.IsGood(dv.StatusCode).ShouldBe(true); - } - - /// - /// Confirms that integer runtime values are preserved when converted to OPC UA data values. - /// - [Fact] - public void FromVtq_Int32() - { - var vtq = Vtq.Good(42); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(42); - } - - /// - /// Confirms that float runtime values are preserved when converted to OPC UA data values. - /// - [Fact] - public void FromVtq_Float() - { - var vtq = Vtq.Good(3.14f); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(3.14f); - } - - /// - /// Confirms that double runtime values are preserved when converted to OPC UA data values. - /// - [Fact] - public void FromVtq_Double() - { - var vtq = Vtq.Good(3.14159); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(3.14159); - } - - /// - /// Confirms that string runtime values are preserved when converted to OPC UA data values. - /// - [Fact] - public void FromVtq_String() - { - var vtq = Vtq.Good("hello"); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe("hello"); - } - - /// - /// Confirms that UTC timestamps remain UTC when a VTQ is converted for OPC UA clients. - /// - [Fact] - public void FromVtq_DateTime_IsUtc() - { - var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); - var vtq = new Vtq(utcTime, utcTime, Quality.Good); - var dv = DataValueConverter.FromVtq(vtq); - ((DateTime)dv.Value).Kind.ShouldBe(DateTimeKind.Utc); - } - - /// - /// Confirms that elapsed-time values are exposed to OPC UA clients in seconds. - /// - [Fact] - public void FromVtq_TimeSpan_ConvertedToSeconds() - { - var vtq = Vtq.Good(TimeSpan.FromMinutes(2.5)); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(150.0); - } - - /// - /// Confirms that string arrays remain arrays when exposed through OPC UA. - /// - [Fact] - public void FromVtq_StringArray() - { - var arr = new[] { "a", "b", "c" }; - var vtq = Vtq.Good(arr); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(arr); - } - - /// - /// Confirms that integer arrays remain arrays when exposed through OPC UA. - /// - [Fact] - public void FromVtq_IntArray() - { - var arr = new[] { 1, 2, 3 }; - var vtq = Vtq.Good(arr); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBe(arr); - } - - /// - /// Confirms that bad runtime quality is translated to a bad OPC UA status code. - /// - [Fact] - public void FromVtq_BadQuality_MapsToStatusCode() - { - var vtq = Vtq.Bad(Quality.BadCommFailure); - var dv = DataValueConverter.FromVtq(vtq); - StatusCode.IsBad(dv.StatusCode).ShouldBe(true); - } - - /// - /// Confirms that uncertain runtime quality is translated to an uncertain OPC UA status code. - /// - [Fact] - public void FromVtq_UncertainQuality() - { - var vtq = Vtq.Uncertain(42); - var dv = DataValueConverter.FromVtq(vtq); - StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true); - } - - /// - /// Confirms that null runtime values remain null when converted for OPC UA. - /// - [Fact] - public void FromVtq_NullValue() - { - var vtq = Vtq.Good(null); - var dv = DataValueConverter.FromVtq(vtq); - dv.Value.ShouldBeNull(); - } - - /// - /// Confirms that a data value can round-trip back into a VTQ without losing the process value or quality. - /// - [Fact] - public void ToVtq_RoundTrip() - { - var original = new Vtq(42, DateTime.UtcNow, Quality.Good); - var dv = DataValueConverter.FromVtq(original); - var roundTrip = DataValueConverter.ToVtq(dv); - - roundTrip.Value.ShouldBe(42); - roundTrip.Quality.ShouldBe(Quality.Good); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerBuildTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerBuildTests.cs deleted file mode 100644 index 648fbf2..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerBuildTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.Collections.Generic; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - /// - /// Verifies the in-memory address-space model built from Galaxy hierarchy and attribute rows. - /// - public class LmxNodeManagerBuildTests - { - /// - /// Creates representative Galaxy hierarchy and attribute rows for address-space builder tests. - /// - /// The hierarchy and attribute rows used by the tests. - private static (List hierarchy, List attributes) CreateTestData() - { - var hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, - IsArea = true - }, - new() - { - GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea", - ParentGobjectId = 1, IsArea = true - }, - new() - { - GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001", - BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", - BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false - } - }; - - var attributes = new List - { - new() - { - GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", - FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", - FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false - }, - new() - { - GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", - FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false - }, - new() - { - GobjectId = 3, TagName = "TestMachine_001", AttributeName = "BatchItems", - FullTagReference = "TestMachine_001.BatchItems[]", MxDataType = 5, IsArray = true, - ArrayDimension = 50 - } - }; - - return (hierarchy, attributes); - } - - /// - /// Confirms that object and variable counts are computed correctly from the seeded Galaxy model. - /// - [Fact] - public void BuildAddressSpace_CreatesCorrectNodeCounts() - { - var (hierarchy, attributes) = CreateTestData(); - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - - model.ObjectCount.ShouldBe(2); // TestMachine_001, DelmiaReceiver - model.VariableCount.ShouldBe(4); // MachineID, DownloadPath, JobStepNumber, BatchItems - } - - /// - /// Confirms that runtime tag references are populated for every published variable. - /// - [Fact] - public void BuildAddressSpace_TagReferencesPopulated() - { - var (hierarchy, attributes) = CreateTestData(); - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - - model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true); - model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true); - model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true); - model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true); - } - - /// - /// Confirms that array attributes are represented in the tag-reference map. - /// - [Fact] - public void BuildAddressSpace_ArrayVariable_HasCorrectInfo() - { - var (hierarchy, attributes) = CreateTestData(); - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - - model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true); - model.NodeIdToTagReference["TestMachine_001.BatchItems"].ShouldBe("TestMachine_001.BatchItems[]"); - } - - /// - /// Confirms that Galaxy areas are not counted as object nodes in the resulting model. - /// - [Fact] - public void BuildAddressSpace_Areas_AreNotCountedAsObjects() - { - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true }, - new() { GobjectId = 2, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 1, IsArea = false } - }; - - var model = AddressSpaceBuilder.Build(hierarchy, new List()); - model.ObjectCount.ShouldBe(1); // Only Obj1, not Area1 - } - - /// - /// Confirms that only top-level Galaxy nodes are returned as roots in the model. - /// - [Fact] - public void BuildAddressSpace_RootNodes_AreTopLevel() - { - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "Root1", BrowseName = "Root1", ParentGobjectId = 0, IsArea = true }, - new() { GobjectId = 2, TagName = "Child1", BrowseName = "Child1", ParentGobjectId = 1, IsArea = false } - }; - - var model = AddressSpaceBuilder.Build(hierarchy, new List()); - model.RootNodes.Count.ShouldBe(1); // Only Root1 is a root - } - - /// - /// Confirms that variables for multiple MX data types are included in the model. - /// - [Fact] - public void BuildAddressSpace_DataTypeMappings() - { - var hierarchy = new List - { - new() { GobjectId = 1, TagName = "Obj", BrowseName = "Obj", ParentGobjectId = 0, IsArea = false } - }; - var attributes = new List - { - new() - { - GobjectId = 1, TagName = "Obj", AttributeName = "BoolAttr", FullTagReference = "Obj.BoolAttr", - MxDataType = 1, IsArray = false - }, - new() - { - GobjectId = 1, TagName = "Obj", AttributeName = "IntAttr", FullTagReference = "Obj.IntAttr", - MxDataType = 2, IsArray = false - }, - new() - { - GobjectId = 1, TagName = "Obj", AttributeName = "FloatAttr", FullTagReference = "Obj.FloatAttr", - MxDataType = 3, IsArray = false - }, - new() - { - GobjectId = 1, TagName = "Obj", AttributeName = "StrAttr", FullTagReference = "Obj.StrAttr", - MxDataType = 5, IsArray = false - } - }; - - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - model.VariableCount.ShouldBe(4); - model.NodeIdToTagReference.Count.ShouldBe(4); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerRebuildTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerRebuildTests.cs deleted file mode 100644 index 4631792..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerRebuildTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Generic; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - /// - /// Verifies rebuild behavior by comparing address-space models before and after metadata changes. - /// - public class LmxNodeManagerRebuildTests - { - /// - /// Confirms that rebuilding with new metadata replaces the old tag-reference set. - /// - [Fact] - public void Rebuild_NewBuild_ReplacesOldData() - { - var hierarchy1 = new List - { - new() { GobjectId = 1, TagName = "OldObj", BrowseName = "OldObj", ParentGobjectId = 0, IsArea = false } - }; - var attrs1 = new List - { - new() - { - GobjectId = 1, TagName = "OldObj", AttributeName = "OldAttr", FullTagReference = "OldObj.OldAttr", - MxDataType = 5, IsArray = false - } - }; - - var model1 = AddressSpaceBuilder.Build(hierarchy1, attrs1); - model1.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(true); - - // Rebuild with new data - var hierarchy2 = new List - { - new() { GobjectId = 2, TagName = "NewObj", BrowseName = "NewObj", ParentGobjectId = 0, IsArea = false } - }; - var attrs2 = new List - { - new() - { - GobjectId = 2, TagName = "NewObj", AttributeName = "NewAttr", FullTagReference = "NewObj.NewAttr", - MxDataType = 2, IsArray = false - } - }; - - var model2 = AddressSpaceBuilder.Build(hierarchy2, attrs2); - - // Old nodes not in new model, new nodes present - model2.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(false); - model2.NodeIdToTagReference.ContainsKey("NewObj.NewAttr").ShouldBe(true); - } - - /// - /// Confirms that object counts are recalculated from the latest rebuild input. - /// - [Fact] - public void Rebuild_UpdatesNodeCounts() - { - var hierarchy1 = new List - { - new() { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false }, - new() { GobjectId = 2, TagName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 0, IsArea = false } - }; - var model1 = AddressSpaceBuilder.Build(hierarchy1, new List()); - model1.ObjectCount.ShouldBe(2); - - var hierarchy2 = new List - { - new() { GobjectId = 3, TagName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 0, IsArea = false } - }; - var model2 = AddressSpaceBuilder.Build(hierarchy2, new List()); - model2.ObjectCount.ShouldBe(1); - } - - /// - /// Confirms that empty metadata produces an empty address-space model. - /// - [Fact] - public void EmptyHierarchy_ProducesEmptyModel() - { - var model = AddressSpaceBuilder.Build(new List(), new List()); - model.RootNodes.ShouldBeEmpty(); - model.NodeIdToTagReference.ShouldBeEmpty(); - model.ObjectCount.ShouldBe(0); - model.VariableCount.ShouldBe(0); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerSubscriptionFaultTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerSubscriptionFaultTests.cs deleted file mode 100644 index fd03f2a..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/LmxNodeManagerSubscriptionFaultTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - /// - /// Verifies that subscription and unsubscription failures in the MXAccess client - /// are handled gracefully by the node manager instead of silently lost. - /// - public class LmxNodeManagerSubscriptionFaultTests - { - /// - /// Confirms that a faulted SubscribeAsync is caught and logged rather than silently discarded. - /// - [Fact] - public async Task SubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang() - { - var mxClient = new FakeMxAccessClient - { - SubscribeException = new InvalidOperationException("COM connection lost") - }; - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - - // SubscribeTag should catch the fault — not throw and not hang - Should.NotThrow(() => nodeManager.SubscribeTag("TestMachine_001.MachineID")); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that a faulted UnsubscribeAsync is caught and logged rather than silently discarded. - /// - [Fact] - public async Task UnsubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang() - { - var mxClient = new FakeMxAccessClient(); - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - - // Subscribe first (succeeds) - nodeManager.SubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(1); - - // Now inject fault for unsubscribe - mxClient.UnsubscribeException = new InvalidOperationException("COM connection lost"); - - // UnsubscribeTag should catch the fault — not throw and not hang - Should.NotThrow(() => nodeManager.UnsubscribeTag("TestMachine_001.MachineID")); - } - finally - { - await fixture.DisposeAsync(); - } - } - - /// - /// Confirms that subscription failure does not corrupt the ref-count bookkeeping, - /// allowing a retry to succeed after the fault clears. - /// - [Fact] - public async Task SubscribeTag_AfterFaultClears_CanSubscribeAgain() - { - var mxClient = new FakeMxAccessClient - { - SubscribeException = new InvalidOperationException("transient fault") - }; - var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient); - await fixture.InitializeAsync(); - try - { - var nodeManager = fixture.Service.NodeManagerInstance!; - - // First subscribe faults (caught) - nodeManager.SubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(0); // subscribe failed - - // Clear the fault - mxClient.SubscribeException = null; - - // Unsubscribe to reset ref count, then subscribe again - nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); - nodeManager.SubscribeTag("TestMachine_001.MachineID"); - mxClient.ActiveSubscriptionCount.ShouldBe(1); - } - finally - { - await fixture.DisposeAsync(); - } - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/OpcUaQualityMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/OpcUaQualityMapperTests.cs deleted file mode 100644 index 06dd26c..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/OpcUa/OpcUaQualityMapperTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa -{ - /// - /// Verifies translation between bridge quality values and OPC UA status codes. - /// - public class OpcUaQualityMapperTests - { - /// - /// Confirms that good bridge quality maps to an OPC UA good status. - /// - [Fact] - public void Good_MapsToGoodStatusCode() - { - var sc = OpcUaQualityMapper.ToStatusCode(Quality.Good); - StatusCode.IsGood(sc).ShouldBe(true); - } - - /// - /// Confirms that bad bridge quality maps to an OPC UA bad status. - /// - [Fact] - public void Bad_MapsToBadStatusCode() - { - var sc = OpcUaQualityMapper.ToStatusCode(Quality.Bad); - StatusCode.IsBad(sc).ShouldBe(true); - } - - /// - /// Confirms that uncertain bridge quality maps to an OPC UA uncertain status. - /// - [Fact] - public void Uncertain_MapsToUncertainStatusCode() - { - var sc = OpcUaQualityMapper.ToStatusCode(Quality.Uncertain); - StatusCode.IsUncertain(sc).ShouldBe(true); - } - - /// - /// Confirms that communication failures map to a bad OPC UA status code. - /// - [Fact] - public void BadCommFailure_MapsCorrectly() - { - var sc = OpcUaQualityMapper.ToStatusCode(Quality.BadCommFailure); - StatusCode.IsBad(sc).ShouldBe(true); - } - - /// - /// Confirms that the OPC UA good status maps back to bridge good quality. - /// - [Fact] - public void FromStatusCode_Good() - { - var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Good); - q.ShouldBe(Quality.Good); - } - - /// - /// Confirms that the OPC UA bad status maps back to bridge bad quality. - /// - [Fact] - public void FromStatusCode_Bad() - { - var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Bad); - q.ShouldBe(Quality.Bad); - } - - /// - /// Confirms that the OPC UA uncertain status maps back to bridge uncertain quality. - /// - [Fact] - public void FromStatusCode_Uncertain() - { - var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Uncertain); - q.ShouldBe(Quality.Uncertain); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyConfigurationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyConfigurationTests.cs deleted file mode 100644 index c7c2686..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyConfigurationTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy -{ - public class RedundancyConfigurationTests - { - [Fact] - public void DefaultConfig_Disabled() - { - var config = new RedundancyConfiguration(); - config.Enabled.ShouldBe(false); - } - - [Fact] - public void DefaultConfig_ModeWarm() - { - var config = new RedundancyConfiguration(); - config.Mode.ShouldBe("Warm"); - } - - [Fact] - public void DefaultConfig_RolePrimary() - { - var config = new RedundancyConfiguration(); - config.Role.ShouldBe("Primary"); - } - - [Fact] - public void DefaultConfig_EmptyServerUris() - { - var config = new RedundancyConfiguration(); - config.ServerUris.ShouldBeEmpty(); - } - - [Fact] - public void DefaultConfig_ServiceLevelBase200() - { - var config = new RedundancyConfiguration(); - config.ServiceLevelBase.ShouldBe(200); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyModeResolverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyModeResolverTests.cs deleted file mode 100644 index 336fc11..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/RedundancyModeResolverTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy -{ - public class RedundancyModeResolverTests - { - [Fact] - public void Resolve_Disabled_ReturnsNone() - { - RedundancyModeResolver.Resolve("Warm", false).ShouldBe(RedundancySupport.None); - } - - [Fact] - public void Resolve_Warm_ReturnsWarm() - { - RedundancyModeResolver.Resolve("Warm", true).ShouldBe(RedundancySupport.Warm); - } - - [Fact] - public void Resolve_Hot_ReturnsHot() - { - RedundancyModeResolver.Resolve("Hot", true).ShouldBe(RedundancySupport.Hot); - } - - [Fact] - public void Resolve_Unknown_FallsBackToNone() - { - RedundancyModeResolver.Resolve("Transparent", true).ShouldBe(RedundancySupport.None); - } - - [Fact] - public void Resolve_CaseInsensitive() - { - RedundancyModeResolver.Resolve("warm", true).ShouldBe(RedundancySupport.Warm); - RedundancyModeResolver.Resolve("WARM", true).ShouldBe(RedundancySupport.Warm); - RedundancyModeResolver.Resolve("hot", true).ShouldBe(RedundancySupport.Hot); - } - - [Fact] - public void Resolve_Null_FallsBackToNone() - { - RedundancyModeResolver.Resolve(null!, true).ShouldBe(RedundancySupport.None); - } - - [Fact] - public void Resolve_Empty_FallsBackToNone() - { - RedundancyModeResolver.Resolve("", true).ShouldBe(RedundancySupport.None); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/ServiceLevelCalculatorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/ServiceLevelCalculatorTests.cs deleted file mode 100644 index be5b0e3..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Redundancy/ServiceLevelCalculatorTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy -{ - public class ServiceLevelCalculatorTests - { - private readonly ServiceLevelCalculator _calculator = new(); - - [Fact] - public void FullyHealthy_Primary_ReturnsBase() - { - _calculator.Calculate(200, true, true).ShouldBe((byte)200); - } - - [Fact] - public void FullyHealthy_Secondary_ReturnsBaseMinusFifty() - { - _calculator.Calculate(150, true, true).ShouldBe((byte)150); - } - - [Fact] - public void MxAccessDown_ReducesServiceLevel() - { - _calculator.Calculate(200, false, true).ShouldBe((byte)100); - } - - [Fact] - public void DbDown_ReducesServiceLevel() - { - _calculator.Calculate(200, true, false).ShouldBe((byte)150); - } - - [Fact] - public void BothDown_ReturnsZero() - { - _calculator.Calculate(200, false, false).ShouldBe((byte)0); - } - - [Fact] - public void ClampedTo255() - { - _calculator.Calculate(255, true, true).ShouldBe((byte)255); - } - - [Fact] - public void ClampedToZero() - { - _calculator.Calculate(50, false, true).ShouldBe((byte)0); - } - - [Fact] - public void ZeroBase_BothHealthy_ReturnsZero() - { - _calculator.Calculate(0, true, true).ShouldBe((byte)0); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/SampleTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/SampleTest.cs deleted file mode 100644 index 7cfe624..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/SampleTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Tests -{ - /// - /// Placeholder unit test that keeps the unit test project wired into the solution. - /// - public class SampleTest - { - /// - /// Confirms that the unit test assembly is executing. - /// - [Fact] - public void Placeholder_ShouldPass() - { - true.ShouldBeTrue(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileConfigurationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileConfigurationTests.cs deleted file mode 100644 index d08c344..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileConfigurationTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Security -{ - public class SecurityProfileConfigurationTests - { - [Fact] - public void DefaultConfig_HasNoneProfile() - { - var config = new SecurityProfileConfiguration(); - config.Profiles.ShouldContain("None"); - config.Profiles.Count.ShouldBe(1); - } - - [Fact] - public void DefaultConfig_AutoAcceptTrue() - { - var config = new SecurityProfileConfiguration(); - config.AutoAcceptClientCertificates.ShouldBe(true); - } - - [Fact] - public void DefaultConfig_RejectSha1True() - { - var config = new SecurityProfileConfiguration(); - config.RejectSHA1Certificates.ShouldBe(true); - } - - [Fact] - public void DefaultConfig_MinKeySize2048() - { - var config = new SecurityProfileConfiguration(); - config.MinimumCertificateKeySize.ShouldBe(2048); - } - - [Fact] - public void DefaultConfig_PkiRootPathNull() - { - var config = new SecurityProfileConfiguration(); - config.PkiRootPath.ShouldBeNull(); - } - - [Fact] - public void DefaultConfig_CertificateSubjectNull() - { - var config = new SecurityProfileConfiguration(); - config.CertificateSubject.ShouldBeNull(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileResolverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileResolverTests.cs deleted file mode 100644 index 5c3be87..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Security/SecurityProfileResolverTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Collections.Generic; -using Opc.Ua; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Security -{ - public class SecurityProfileResolverTests - { - [Fact] - public void Resolve_DefaultNone_ReturnsSingleNonePolicy() - { - var result = SecurityProfileResolver.Resolve(new List { "None" }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None); - } - - [Fact] - public void Resolve_SignProfile_ReturnsBasic256Sha256Sign() - { - var result = SecurityProfileResolver.Resolve(new List { "Basic256Sha256-Sign" }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.Sign); - result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); - } - - [Fact] - public void Resolve_SignAndEncryptProfile_ReturnsBasic256Sha256SignAndEncrypt() - { - var result = SecurityProfileResolver.Resolve(new List { "Basic256Sha256-SignAndEncrypt" }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); - result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); - } - - [Fact] - public void Resolve_MultipleProfiles_ReturnsExpectedPolicies() - { - var result = SecurityProfileResolver.Resolve(new List - { - "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt" - }); - - result.Count.ShouldBe(3); - result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None); - result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign); - result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.SignAndEncrypt); - } - - [Fact] - public void Resolve_DuplicateProfiles_Deduplicated() - { - var result = SecurityProfileResolver.Resolve(new List - { - "None", "None", "Basic256Sha256-Sign", "Basic256Sha256-Sign" - }); - - result.Count.ShouldBe(2); - } - - [Fact] - public void Resolve_UnknownProfile_SkippedWithWarning() - { - var result = SecurityProfileResolver.Resolve(new List - { - "None", "SomeUnknownProfile" - }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - } - - [Fact] - public void Resolve_EmptyList_FallsBackToNone() - { - var result = SecurityProfileResolver.Resolve(new List()); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None); - } - - [Fact] - public void Resolve_NullList_FallsBackToNone() - { - var result = SecurityProfileResolver.Resolve(null!); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - } - - [Fact] - public void Resolve_AllUnknownProfiles_FallsBackToNone() - { - var result = SecurityProfileResolver.Resolve(new List { "Bogus", "AlsoBogus" }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - } - - [Fact] - public void Resolve_CaseInsensitive() - { - var result = SecurityProfileResolver.Resolve(new List { "none", "BASIC256SHA256-SIGN" }); - - result.Count.ShouldBe(2); - result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None); - result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign); - } - - [Fact] - public void Resolve_WhitespaceEntries_Skipped() - { - var result = SecurityProfileResolver.Resolve(new List { "", " ", "None" }); - - result.Count.ShouldBe(1); - result[0].SecurityMode.ShouldBe(MessageSecurityMode.None); - } - - [Fact] - public void ValidProfileNames_ContainsExpectedEntries() - { - var names = SecurityProfileResolver.ValidProfileNames; - - names.ShouldContain("None"); - names.ShouldContain("Basic256Sha256-Sign"); - names.ShouldContain("Basic256Sha256-SignAndEncrypt"); - names.ShouldContain("Aes128_Sha256_RsaOaep-Sign"); - names.ShouldContain("Aes128_Sha256_RsaOaep-SignAndEncrypt"); - names.ShouldContain("Aes256_Sha256_RsaPss-Sign"); - names.ShouldContain("Aes256_Sha256_RsaPss-SignAndEncrypt"); - names.Count.ShouldBe(7); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/HealthCheckServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/HealthCheckServiceTests.cs deleted file mode 100644 index f3ce3b7..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/HealthCheckServiceTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.Status; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Status -{ - /// - /// Verifies how the dashboard health service classifies bridge health from connection state and metrics. - /// - public class HealthCheckServiceTests - { - private readonly HealthCheckService _sut = new(); - - /// - /// Confirms that a disconnected runtime is reported as unhealthy. - /// - [Fact] - public void NotConnected_ReturnsUnhealthy() - { - var result = _sut.CheckHealth(ConnectionState.Disconnected, null); - result.Status.ShouldBe("Unhealthy"); - result.Color.ShouldBe("red"); - result.Message.ShouldContain("not connected"); - } - - /// - /// Confirms that a connected runtime with no metrics history is still considered healthy. - /// - [Fact] - public void Connected_NoMetrics_ReturnsHealthy() - { - var result = _sut.CheckHealth(ConnectionState.Connected, null); - result.Status.ShouldBe("Healthy"); - result.Color.ShouldBe("green"); - } - - /// - /// Confirms that good success-rate metrics keep the service in a healthy state. - /// - [Fact] - public void Connected_GoodMetrics_ReturnsHealthy() - { - using var metrics = new PerformanceMetrics(); - for (var i = 0; i < 200; i++) - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10)); - - var result = _sut.CheckHealth(ConnectionState.Connected, metrics); - result.Status.ShouldBe("Healthy"); - } - - /// - /// Confirms that poor operation success rates degrade the reported health state. - /// - [Fact] - public void Connected_LowSuccessRate_ReturnsDegraded() - { - using var metrics = new PerformanceMetrics(); - for (var i = 0; i < 40; i++) - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10)); - for (var i = 0; i < 80; i++) - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false); - - var result = _sut.CheckHealth(ConnectionState.Connected, metrics); - result.Status.ShouldBe("Degraded"); - result.Color.ShouldBe("yellow"); - } - - /// - /// Confirms that the boolean health helper reports true when the runtime is connected. - /// - [Fact] - public void IsHealthy_Connected_ReturnsTrue() - { - _sut.IsHealthy(ConnectionState.Connected, null).ShouldBe(true); - } - - /// - /// Confirms that the boolean health helper reports false when the runtime is disconnected. - /// - [Fact] - public void IsHealthy_Disconnected_ReturnsFalse() - { - _sut.IsHealthy(ConnectionState.Disconnected, null).ShouldBe(false); - } - - /// - /// Confirms that the error connection state is treated as unhealthy. - /// - [Fact] - public void Error_ReturnsUnhealthy() - { - var result = _sut.CheckHealth(ConnectionState.Error, null); - result.Status.ShouldBe("Unhealthy"); - } - - /// - /// Confirms that the reconnecting state is treated as unhealthy while recovery is in progress. - /// - [Fact] - public void Reconnecting_ReturnsUnhealthy() - { - var result = _sut.CheckHealth(ConnectionState.Reconnecting, null); - result.Status.ShouldBe("Unhealthy"); - } - - /// - /// Historian enabled but plugin failed to load → Degraded with the plugin error in the message. - /// - [Fact] - public void HistorianEnabled_PluginLoadFailed_ReturnsDegraded() - { - var historian = new HistorianStatusInfo - { - Enabled = true, - PluginStatus = "LoadFailed", - PluginError = "aahClientManaged.dll could not be loaded" - }; - - var result = _sut.CheckHealth(ConnectionState.Connected, null, historian); - - result.Status.ShouldBe("Degraded"); - result.Color.ShouldBe("yellow"); - result.Message.ShouldContain("LoadFailed"); - result.Message.ShouldContain("aahClientManaged.dll"); - } - - /// - /// Historian disabled is healthy regardless of plugin status string. - /// - [Fact] - public void HistorianDisabled_ReturnsHealthy() - { - var historian = new HistorianStatusInfo - { - Enabled = false, - PluginStatus = "Disabled" - }; - - _sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy"); - } - - /// - /// Historian enabled and plugin loaded is healthy. - /// - [Fact] - public void HistorianEnabled_PluginLoaded_ReturnsHealthy() - { - var historian = new HistorianStatusInfo { Enabled = true, PluginStatus = "Loaded" }; - _sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy"); - } - - /// - /// HistoryRead operations degrade after only 11 samples with <50% success rate - /// (lower threshold than the regular 100-sample rule). - /// - [Fact] - public void HistoryReadLowSuccessRate_WithLowSampleCount_ReturnsDegraded() - { - using var metrics = new PerformanceMetrics(); - for (var i = 0; i < 4; i++) - metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10)); - for (var i = 0; i < 8; i++) - metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false); - - var result = _sut.CheckHealth(ConnectionState.Connected, metrics); - - result.Status.ShouldBe("Degraded"); - result.Message.ShouldContain("HistoryReadRaw"); - } - - /// - /// A HistoryRead sample under the 10-sample threshold does not degrade the service. - /// - [Fact] - public void HistoryReadLowSuccessRate_BelowThreshold_ReturnsHealthy() - { - using var metrics = new PerformanceMetrics(); - for (var i = 0; i < 5; i++) - metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false); - - _sut.CheckHealth(ConnectionState.Connected, metrics).Status.ShouldBe("Healthy"); - } - - /// - /// Alarm acknowledge write failures are latched — any non-zero count degrades the service. - /// - [Fact] - public void AlarmAckWriteFailures_AnyCount_ReturnsDegraded() - { - var alarms = new AlarmStatusInfo { TrackingEnabled = true, AckWriteFailures = 1 }; - - var result = _sut.CheckHealth(ConnectionState.Connected, null, null, alarms); - - result.Status.ShouldBe("Degraded"); - result.Message.ShouldContain("Alarm acknowledge"); - } - - /// - /// Alarm tracking disabled ignores any failure count. - /// - [Fact] - public void AlarmAckWriteFailures_TrackingDisabled_ReturnsHealthy() - { - var alarms = new AlarmStatusInfo { TrackingEnabled = false, AckWriteFailures = 99 }; - - _sut.CheckHealth(ConnectionState.Connected, null, null, alarms).Status.ShouldBe("Healthy"); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusReportServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusReportServiceTests.cs deleted file mode 100644 index 6675945..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusReportServiceTests.cs +++ /dev/null @@ -1,428 +0,0 @@ -using System; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; -using ZB.MOM.WW.OtOpcUa.Host.Metrics; -using ZB.MOM.WW.OtOpcUa.Host.Status; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Status -{ - /// - /// Verifies the HTML, JSON, and health snapshots generated for the operator status dashboard. - /// - public class StatusReportServiceTests - { - /// - /// Confirms that the generated HTML contains every dashboard panel expected by operators. - /// - [Fact] - public void GenerateHtml_ContainsAllPanels() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - - html.ShouldContain("Connection"); - html.ShouldContain("Health"); - html.ShouldContain("Subscriptions"); - html.ShouldContain("Galaxy Info"); - html.ShouldContain("Operations"); - } - - /// - /// Confirms that the generated HTML includes the configured auto-refresh meta tag. - /// - [Fact] - public void GenerateHtml_ContainsMetaRefresh() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - html.ShouldContain("meta http-equiv='refresh' content='10'"); - } - - /// - /// Confirms that the connection panel renders the current runtime connection state. - /// - [Fact] - public void GenerateHtml_ConnectionPanel_ShowsState() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - html.ShouldContain("Connected"); - } - - /// - /// Confirms that the Galaxy panel renders the bridged Galaxy name. - /// - [Fact] - public void GenerateHtml_GalaxyPanel_ShowsName() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - html.ShouldContain("TestGalaxy"); - } - - /// - /// Confirms that the operations table renders the expected performance metric headers. - /// - [Fact] - public void GenerateHtml_OperationsTable_ShowsHeaders() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - html.ShouldContain("Count"); - html.ShouldContain("Success Rate"); - html.ShouldContain("Avg (ms)"); - html.ShouldContain("Min (ms)"); - html.ShouldContain("Max (ms)"); - html.ShouldContain("P95 (ms)"); - } - - /// - /// The dashboard title shows the service version inline so operators can identify the deployed - /// build without scrolling, and the standalone footer panel is gone. - /// - [Fact] - public void GenerateHtml_Title_ShowsVersion_NoFooter() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - - html.ShouldContain("

LmxOpcUa Status Dashboard"); - html.ShouldContain("class='version'"); - html.ShouldNotContain("

Footer

"); - html.ShouldNotContain("Generated:"); - } - - /// - /// Confirms that the generated JSON includes the major dashboard sections. - /// - [Fact] - public void GenerateJson_Deserializes() - { - var sut = CreateService(); - var json = sut.GenerateJson(); - - json.ShouldNotBeNullOrWhiteSpace(); - json.ShouldContain("Connection"); - json.ShouldContain("Health"); - json.ShouldContain("Subscriptions"); - json.ShouldContain("Galaxy"); - json.ShouldContain("Operations"); - json.ShouldContain("Historian"); - json.ShouldContain("Alarms"); - json.ShouldContain("Footer"); - } - - /// - /// The dashboard JSON exposes the historian plugin status so operators can distinguish - /// "disabled by config" from "plugin crashed on load." - /// - [Fact] - public void GenerateJson_Historian_IncludesPluginStatus() - { - var sut = CreateService(); - var json = sut.GenerateJson(); - - json.ShouldContain("PluginStatus"); - json.ShouldContain("PluginPath"); - } - - /// - /// The dashboard JSON exposes alarm counters so operators can see transition/ack activity. - /// - [Fact] - public void GenerateJson_Alarms_IncludesCounters() - { - var sut = CreateService(); - var json = sut.GenerateJson(); - - json.ShouldContain("TrackingEnabled"); - json.ShouldContain("TransitionCount"); - json.ShouldContain("AckWriteFailures"); - } - - /// - /// The Historian and Alarms panels render in the HTML dashboard. - /// - [Fact] - public void GenerateHtml_IncludesHistorianAndAlarmPanels() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - - html.ShouldContain("

Historian

"); - html.ShouldContain("

Alarms

"); - } - - /// - /// The Endpoints panel renders in the HTML dashboard even when no server host has been set, - /// so operators can tell the OPC UA server has not started. - /// - [Fact] - public void GenerateHtml_IncludesEndpointsPanel() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - - html.ShouldContain("

Endpoints

"); - html.ShouldContain("OPC UA server not started"); - } - - /// - /// The dashboard JSON surfaces the alarm filter counters so monitoring clients can verify scope. - /// - [Fact] - public void GenerateJson_Alarms_IncludesFilterCounters() - { - var sut = CreateService(); - var json = sut.GenerateJson(); - - json.ShouldContain("FilterEnabled"); - json.ShouldContain("FilterPatternCount"); - json.ShouldContain("FilterIncludedObjectCount"); - json.ShouldContain("FilterPatterns"); - } - - /// - /// With no filter configured, the Alarms panel renders an explicit "disabled" line so operators - /// know all alarm-bearing objects are being tracked. - /// - [Fact] - public void GenerateHtml_AlarmsPanel_FilterDisabled_ShowsDisabledLine() - { - var sut = CreateService(); - var html = sut.GenerateHtml(); - - html.ShouldContain("Filter: disabled"); - } - - /// - /// The dashboard JSON surfaces the Endpoints section with base-address and security-profile slots - /// so monitoring clients can read them programmatically. - /// - [Fact] - public void GenerateJson_Endpoints_IncludesBaseAddressesAndSecurityProfiles() - { - var sut = CreateService(); - var json = sut.GenerateJson(); - - json.ShouldContain("Endpoints"); - json.ShouldContain("BaseAddresses"); - json.ShouldContain("SecurityProfiles"); - json.ShouldContain("UserTokenPolicies"); - } - - /// - /// The /api/health payload exposes Historian and Alarms component status. - /// - [Fact] - public void GetHealthData_Components_IncludeHistorianAndAlarms() - { - var sut = CreateService(); - var data = sut.GetHealthData(); - - data.Components.Historian.ShouldNotBeNullOrEmpty(); - data.Components.Alarms.ShouldNotBeNullOrEmpty(); - } - - /// - /// Confirms that the report service reports healthy when the runtime connection is up. - /// - [Fact] - public void IsHealthy_WhenConnected_ReturnsTrue() - { - var sut = CreateService(); - sut.IsHealthy().ShouldBe(true); - } - - /// - /// Confirms that the report service reports unhealthy when the runtime connection is down. - /// - [Fact] - public void IsHealthy_WhenDisconnected_ReturnsFalse() - { - var mxClient = new FakeMxAccessClient { State = ConnectionState.Disconnected }; - var sut = new StatusReportService(new HealthCheckService(), 10); - sut.SetComponents(mxClient, null, null, null); - sut.IsHealthy().ShouldBe(false); - } - - [Fact] - public void GetHealthData_WhenConnected_ReturnsHealthyStatus() - { - var sut = CreateService(); - var data = sut.GetHealthData(); - - data.Status.ShouldBe("Healthy"); - data.Components.MxAccess.ShouldBe("Connected"); - data.Components.Database.ShouldBe("Connected"); - } - - [Fact] - public void GetHealthData_WhenDisconnected_ReturnsUnhealthyStatus() - { - var mxClient = new FakeMxAccessClient { State = ConnectionState.Disconnected }; - var galaxyStats = new GalaxyRepositoryStats { DbConnected = false }; - var sut = new StatusReportService(new HealthCheckService(), 10); - sut.SetComponents(mxClient, null, galaxyStats, null); - - var data = sut.GetHealthData(); - - data.Status.ShouldBe("Unhealthy"); - data.ServiceLevel.ShouldBe((byte)0); - data.Components.MxAccess.ShouldBe("Disconnected"); - data.Components.Database.ShouldBe("Disconnected"); - } - - [Fact] - public void GetHealthData_NoRedundancy_ServiceLevel255WhenHealthy() - { - var sut = CreateService(); - var data = sut.GetHealthData(); - - data.RedundancyEnabled.ShouldBe(false); - data.ServiceLevel.ShouldBe((byte)255); - data.RedundancyRole.ShouldBeNull(); - data.RedundancyMode.ShouldBeNull(); - } - - [Fact] - public void GetHealthData_WithRedundancy_IncludesRoleAndServiceLevel() - { - var sut = CreateServiceWithRedundancy("Primary"); - var data = sut.GetHealthData(); - - data.RedundancyEnabled.ShouldBe(true); - data.RedundancyRole.ShouldBe("Primary"); - data.RedundancyMode.ShouldBe("Warm"); - data.ServiceLevel.ShouldBe((byte)200); - } - - [Fact] - public void GetHealthData_SecondaryRole_LowerServiceLevel() - { - var sut = CreateServiceWithRedundancy("Secondary"); - var data = sut.GetHealthData(); - - data.ServiceLevel.ShouldBe((byte)150); - } - - [Fact] - public void GetHealthData_ContainsUptime() - { - var sut = CreateService(); - var data = sut.GetHealthData(); - - data.Uptime.ShouldNotBeNullOrWhiteSpace(); - } - - [Fact] - public void GetHealthData_ContainsTimestamp() - { - var sut = CreateService(); - var data = sut.GetHealthData(); - - data.Timestamp.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1)); - } - - [Fact] - public void GenerateHealthJson_ContainsExpectedFields() - { - var sut = CreateService(); - var json = sut.GenerateHealthJson(); - - json.ShouldContain("Status"); - json.ShouldContain("ServiceLevel"); - json.ShouldContain("Components"); - json.ShouldContain("MxAccess"); - json.ShouldContain("Database"); - json.ShouldContain("OpcUaServer"); - json.ShouldContain("Uptime"); - } - - [Fact] - public void GenerateHealthHtml_ContainsStatusBadge() - { - var sut = CreateService(); - var html = sut.GenerateHealthHtml(); - - html.ShouldContain("HEALTHY"); - html.ShouldContain("SERVICE LEVEL"); - html.ShouldContain("255"); - } - - [Fact] - public void GenerateHealthHtml_ContainsComponentCards() - { - var sut = CreateService(); - var html = sut.GenerateHealthHtml(); - - html.ShouldContain("MXAccess"); - html.ShouldContain("Galaxy Database"); - html.ShouldContain("OPC UA Server"); - } - - [Fact] - public void GenerateHealthHtml_WithRedundancy_ShowsRoleAndMode() - { - var sut = CreateServiceWithRedundancy("Primary"); - var html = sut.GenerateHealthHtml(); - - html.ShouldContain("Primary"); - html.ShouldContain("Warm"); - } - - [Fact] - public void GenerateHealthHtml_ContainsAutoRefresh() - { - var sut = CreateService(); - var html = sut.GenerateHealthHtml(); - html.ShouldContain("meta http-equiv='refresh' content='10'"); - } - - /// - /// Creates a status report service preloaded with representative runtime, Galaxy, and metrics data. - /// - /// A configured status report service for dashboard assertions. - private static StatusReportService CreateService() - { - var mxClient = new FakeMxAccessClient(); - using var metrics = new PerformanceMetrics(); - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10)); - metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20)); - - var galaxyStats = new GalaxyRepositoryStats - { - GalaxyName = "TestGalaxy", - DbConnected = true, - LastDeployTime = new DateTime(2024, 6, 1), - ObjectCount = 42, - AttributeCount = 200, - LastRebuildTime = DateTime.UtcNow - }; - - var sut = new StatusReportService(new HealthCheckService(), 10); - sut.SetComponents(mxClient, metrics, galaxyStats, null); - return sut; - } - - private static StatusReportService CreateServiceWithRedundancy(string role) - { - var mxClient = new FakeMxAccessClient(); - var galaxyStats = new GalaxyRepositoryStats { GalaxyName = "TestGalaxy", DbConnected = true }; - var redundancyConfig = new RedundancyConfiguration - { - Enabled = true, - Mode = "Warm", - Role = role, - ServiceLevelBase = 200 - }; - var sut = new StatusReportService(new HealthCheckService(), 10); - sut.SetComponents(mxClient, null, galaxyStats, null, null, redundancyConfig, "urn:test:instance1"); - return sut; - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusWebServerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusWebServerTests.cs deleted file mode 100644 index 928fd41..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Status/StatusWebServerTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Status; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Status -{ - /// - /// Verifies the lightweight HTTP dashboard host that exposes bridge status to operators. - /// - public class StatusWebServerTests : IDisposable - { - private readonly HttpClient _client; - private readonly int _port; - private readonly StatusWebServer _server; - - /// - /// Starts a status web server on a random test port and prepares an HTTP client for endpoint assertions. - /// - public StatusWebServerTests() - { - _port = new Random().Next(18000, 19000); - var reportService = new StatusReportService(new HealthCheckService(), 10); - var mxClient = new FakeMxAccessClient(); - reportService.SetComponents(mxClient, null, null, null); - _server = new StatusWebServer(reportService, _port); - _server.Start(); - _client = new HttpClient { BaseAddress = new Uri($"http://localhost:{_port}") }; - } - - /// - /// Disposes the test HTTP client and stops the status web server. - /// - public void Dispose() - { - _client.Dispose(); - _server.Dispose(); - } - - /// - /// Confirms that the dashboard root responds with HTML content. - /// - [Fact] - public async Task Root_ReturnsHtml200() - { - var response = await _client.GetAsync("/"); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); - } - - /// - /// Confirms that the JSON status endpoint responds successfully. - /// - [Fact] - public async Task ApiStatus_ReturnsJson200() - { - var response = await _client.GetAsync("/api/status"); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json"); - } - - /// - /// Confirms that the health endpoint returns HTTP 200 when the bridge is healthy. - /// - [Fact] - public async Task ApiHealth_Returns200WhenHealthy() - { - var response = await _client.GetAsync("/api/health"); - // FakeMxAccessClient starts as Connected → healthy - response.StatusCode.ShouldBe(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - body.ShouldContain("healthy"); - } - - /// - /// Confirms that unknown dashboard routes return HTTP 404. - /// - [Fact] - public async Task UnknownPath_Returns404() - { - var response = await _client.GetAsync("/unknown"); - response.StatusCode.ShouldBe(HttpStatusCode.NotFound); - } - - /// - /// Confirms that unsupported HTTP methods are rejected with HTTP 405. - /// - [Fact] - public async Task PostMethod_Returns405() - { - var response = await _client.PostAsync("/", new StringContent("")); - response.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed); - } - - /// - /// Confirms that Start() returns false and logs a failure when the target port is - /// already bound by another listener. Regression guard for the stability-review 2026-04-13 - /// Finding 2: OpcUaService now surfaces this return value into DashboardStartFailed. - /// - [Fact] - public void Start_WhenPortInUse_ReturnsFalse() - { - var port = new Random().Next(19000, 19500); - using var blocker = new HttpListener(); - blocker.Prefixes.Add($"http://localhost:{port}/"); - blocker.Start(); - - var reportService = new StatusReportService(new HealthCheckService(), 10); - reportService.SetComponents(new FakeMxAccessClient(), null, null, null); - using var contested = new StatusWebServer(reportService, port); - - contested.Start().ShouldBeFalse(); - } - - /// - /// Confirms that cache-control headers disable caching for dashboard responses. - /// - [Fact] - public async Task CacheHeaders_Present() - { - var response = await _client.GetAsync("/"); - response.Headers.CacheControl?.NoCache.ShouldBe(true); - response.Headers.CacheControl?.NoStore.ShouldBe(true); - } - - /// - /// Confirms that the /health route returns an HTML health page. - /// - [Fact] - public async Task HealthPage_ReturnsHtml200() - { - var response = await _client.GetAsync("/health"); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); - var body = await response.Content.ReadAsStringAsync(); - body.ShouldContain("SERVICE LEVEL"); - body.ShouldContain("MXAccess"); - } - - /// - /// Confirms that /api/health returns rich JSON with component health details. - /// - [Fact] - public async Task ApiHealth_ReturnsRichJson() - { - var response = await _client.GetAsync("/api/health"); - response.StatusCode.ShouldBe(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json"); - var body = await response.Content.ReadAsStringAsync(); - body.ShouldContain("ServiceLevel"); - body.ShouldContain("Components"); - body.ShouldContain("Uptime"); - } - - /// - /// Confirms that the server can be started and stopped cleanly. - /// - [Fact] - public void StartStop_DoesNotThrow() - { - var server2 = new StatusWebServer( - new StatusReportService(new HealthCheckService(), 10), - new Random().Next(19000, 20000)); - server2.Start().ShouldBe(true); - server2.IsRunning.ShouldBe(true); - server2.Stop(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Utilities/SyncOverAsyncTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Utilities/SyncOverAsyncTests.cs deleted file mode 100644 index 5bce7ad..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Utilities/SyncOverAsyncTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Utilities; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Utilities -{ - /// - /// Tests for the bounded sync-over-async wrapper introduced by stability review 2026-04-13 - /// Finding 3. The wrapper is a backstop applied at every LmxNodeManager sync-over-async site - /// (Read, Write, HistoryRead*, BuildAddressSpace probe sync). - /// - public class SyncOverAsyncTests - { - [Fact] - public void WaitSync_CompletedTask_ReturnsResult() - { - var task = Task.FromResult(42); - SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "test").ShouldBe(42); - } - - [Fact] - public void WaitSync_CompletedNonGenericTask_Returns() - { - var task = Task.CompletedTask; - Should.NotThrow(() => SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "test")); - } - - [Fact] - public void WaitSync_NeverCompletingTask_ThrowsTimeoutException() - { - var tcs = new TaskCompletionSource(); - var ex = Should.Throw(() => - SyncOverAsync.WaitSync(tcs.Task, TimeSpan.FromMilliseconds(100), "op")); - ex.Message.ShouldContain("op"); - } - - [Fact] - public void WaitSync_NeverCompletingNonGenericTask_ThrowsTimeoutException() - { - var tcs = new TaskCompletionSource(); - Should.Throw(() => - SyncOverAsync.WaitSync((Task)tcs.Task, TimeSpan.FromMilliseconds(100), "op")); - } - - [Fact] - public void WaitSync_FaultedNonGenericTask_UnwrapsInnerException() - { - var task = Task.FromException(new InvalidOperationException("boom")); - Should.Throw(() => - SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "op")); - } - - [Fact] - public void WaitSync_FaultedGenericTask_UnwrapsInnerException() - { - var task = Task.FromException(new InvalidOperationException("boom")); - Should.Throw(() => - SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "op")); - } - - [Fact] - public void WaitSync_NullTask_ThrowsArgumentNullException() - { - Should.Throw(() => - SyncOverAsync.WaitSync((Task)null!, TimeSpan.FromSeconds(1), "op")); - Should.Throw(() => - SyncOverAsync.WaitSync((Task)null!, TimeSpan.FromSeconds(1), "op")); - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ChangeDetectionToRebuildWiringTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ChangeDetectionToRebuildWiringTest.cs deleted file mode 100644 index a889ea1..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ChangeDetectionToRebuildWiringTest.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: Galaxy change detection → OnGalaxyChanged → address space rebuild - /// - public class ChangeDetectionToRebuildWiringTest - { - /// - /// Confirms that a changed deploy timestamp causes the change-detection pipeline to raise another rebuild signal. - /// - [Fact] - public async Task ChangedTimestamp_TriggersRebuild() - { - var repo = new FakeGalaxyRepository - { - LastDeployTime = new DateTime(2024, 1, 1), - Hierarchy = new List - { - new() { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "Obj1", AttributeName = "Attr1", FullTagReference = "Obj1.Attr1", - MxDataType = 5, IsArray = false - } - } - }; - - var rebuildCount = 0; - var service = new ChangeDetectionService(repo, 1); - service.OnGalaxyChanged += () => Interlocked.Increment(ref rebuildCount); - - service.Start(); - await Task.Delay(500); // First poll triggers - rebuildCount.ShouldBeGreaterThanOrEqualTo(1); - - // Change deploy time → should trigger rebuild - repo.LastDeployTime = new DateTime(2024, 2, 1); - await Task.Delay(1500); - service.Stop(); - - rebuildCount.ShouldBeGreaterThanOrEqualTo(2); - service.Dispose(); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/MxAccessToNodeManagerWiringTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/MxAccessToNodeManagerWiringTest.cs deleted file mode 100644 index f22a1eb..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/MxAccessToNodeManagerWiringTest.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: FakeMxProxy OnDataChange → MxAccessClient → OnTagValueChanged → node manager delivery - /// - public class MxAccessToNodeManagerWiringTest - { - /// - /// Confirms that a simulated data change reaches the global tag-value-changed event. - /// - [Fact] - public async Task DataChange_ReachesGlobalHandler() - { - var mxClient = new FakeMxAccessClient(); - string? receivedAddress = null; - Vtq? receivedVtq = null; - - mxClient.OnTagValueChanged += (addr, vtq) => - { - receivedAddress = addr; - receivedVtq = vtq; - }; - - mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(42)); - - receivedAddress.ShouldBe("TestTag.Attr"); - receivedVtq.ShouldNotBeNull(); - receivedVtq.Value.Value.ShouldBe(42); - receivedVtq.Value.Quality.ShouldBe(Quality.Good); - } - - /// - /// Confirms that a simulated data change reaches the stored per-tag subscription callback. - /// - [Fact] - public async Task DataChange_ReachesSubscriptionCallback() - { - var mxClient = new FakeMxAccessClient(); - Vtq? received = null; - - await mxClient.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq); - mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(99)); - - received.ShouldNotBeNull(); - received.Value.Value.ShouldBe(99); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaReadToMxAccessWiringTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaReadToMxAccessWiringTest.cs deleted file mode 100644 index 1c0643b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaReadToMxAccessWiringTest.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: OPC UA Read → NodeManager → IMxAccessClient.ReadAsync with correct full_tag_reference - /// - public class OpcUaReadToMxAccessWiringTest - { - /// - /// Confirms that the resolved OPC UA read path uses the expected full Galaxy tag reference. - /// - [Fact] - public async Task Read_ResolvesCorrectTagReference() - { - var mxClient = new FakeMxAccessClient(); - mxClient.TagValues["DelmiaReceiver_001.DownloadPath"] = Vtq.Good("/some/path"); - - var hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0, - IsArea = false - }, - new() - { - GobjectId = 2, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", - BrowseName = "DelmiaReceiver", ParentGobjectId = 1, IsArea = false - } - }; - var attributes = new List - { - new() - { - GobjectId = 2, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", - FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false - } - }; - - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - - // The model should contain the correct tag reference - model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true); - model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"].ShouldBe("DelmiaReceiver_001.DownloadPath"); - - // The MxAccessClient should be able to read using the tag reference - var vtq = await mxClient.ReadAsync("DelmiaReceiver_001.DownloadPath"); - vtq.Value.ShouldBe("/some/path"); - vtq.Quality.ShouldBe(Quality.Good); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaServiceDashboardFailureTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaServiceDashboardFailureTests.cs deleted file mode 100644 index 3ea4d99..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaServiceDashboardFailureTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Regression for stability review 2026-04-13 Finding 2. Confirms that when the dashboard - /// port is already bound, the service continues to start (degraded mode) and the - /// flag is raised. - /// - public class OpcUaServiceDashboardFailureTests - { - [Fact] - public void Start_DashboardPortInUse_ContinuesInDegradedMode() - { - var dashboardPort = new Random().Next(19500, 19999); - using var blocker = new HttpListener(); - blocker.Prefixes.Add($"http://localhost:{dashboardPort}/"); - blocker.Start(); - - var config = new AppConfiguration - { - OpcUa = new OpcUaConfiguration - { - Port = 14842, - GalaxyName = "TestGalaxy", - EndpointPath = "/LmxOpcUa" - }, - MxAccess = new MxAccessConfiguration { ClientName = "Test" }, - GalaxyRepository = new GalaxyRepositoryConfiguration(), - Dashboard = new DashboardConfiguration { Enabled = true, Port = dashboardPort } - }; - - var proxy = new FakeMxProxy(); - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", - ParentGobjectId = 0, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", - FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false - } - } - }; - - var service = new OpcUaService(config, proxy, repo); - service.Start(); - - try - { - // Service continues despite dashboard bind failure — degraded mode policy. - service.ServerHost.ShouldNotBeNull(); - service.DashboardStartFailed.ShouldBeTrue(); - service.StatusWeb.ShouldBeNull(); - } - finally - { - service.Stop(); - } - } - } -} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaWriteToMxAccessWiringTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaWriteToMxAccessWiringTest.cs deleted file mode 100644 index ab99755..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/OpcUaWriteToMxAccessWiringTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: OPC UA Write → NodeManager → IMxAccessClient.WriteAsync with correct tag+value - /// - public class OpcUaWriteToMxAccessWiringTest - { - /// - /// Confirms that the resolved OPC UA write path targets the expected Galaxy tag reference and payload. - /// - [Fact] - public async Task Write_SendsCorrectTagAndValue() - { - var mxClient = new FakeMxAccessClient(); - - var hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0, - IsArea = false - } - }; - var attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestMachine_001", AttributeName = "MachineCode", - FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false - } - }; - - var model = AddressSpaceBuilder.Build(hierarchy, attributes); - var tagRef = model.NodeIdToTagReference["TestMachine_001.MachineCode"]; - - // Write through MxAccessClient - var result = await mxClient.WriteAsync(tagRef, "NEW_CODE"); - - result.ShouldBe(true); - mxClient.WrittenValues.ShouldContain(w => - w.Tag == "TestMachine_001.MachineCode" && (string)w.Value == "NEW_CODE"); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ServiceStartupSequenceTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ServiceStartupSequenceTest.cs deleted file mode 100644 index a746efd..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ServiceStartupSequenceTest.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Host.Domain; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: OpcUaService Start() creates and wires all components with fakes. - /// - public class ServiceStartupSequenceTest - { - /// - /// Confirms that startup with fake dependencies creates the expected bridge components and state. - /// - [Fact] - public void Start_WithFakes_AllComponentsCreated() - { - var config = new AppConfiguration - { - OpcUa = new OpcUaConfiguration - { - Port = 14840, - GalaxyName = "TestGalaxy", - EndpointPath = "/LmxOpcUa" - }, - MxAccess = new MxAccessConfiguration { ClientName = "Test" }, - GalaxyRepository = new GalaxyRepositoryConfiguration(), - Dashboard = new DashboardConfiguration { Enabled = false } // Don't start HTTP listener in tests - }; - - var proxy = new FakeMxProxy(); - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", - FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false - } - } - }; - - var service = new OpcUaService(config, proxy, repo); - service.Start(); - - try - { - // Verify all components were created - service.MxClient.ShouldNotBeNull(); - service.MxClient!.State.ShouldBe(ConnectionState.Connected); - service.Metrics.ShouldNotBeNull(); - service.ServerHost.ShouldNotBeNull(); - service.ChangeDetectionInstance.ShouldNotBeNull(); - service.GalaxyStatsInstance.ShouldNotBeNull(); - service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy"); - service.GalaxyStatsInstance.DbConnected.ShouldBe(true); - service.StatusReportInstance.ShouldNotBeNull(); - - // Dashboard disabled → no web server - service.StatusWeb.ShouldBeNull(); - - // MxProxy should have been registered - proxy.IsRegistered.ShouldBe(true); - } - finally - { - service.Stop(); - } - } - - /// - /// Confirms that when MXAccess is initially unavailable, the background monitor reconnects it later. - /// - [Fact] - public async Task Start_WhenMxAccessIsInitiallyDown_MonitorReconnectsInBackground() - { - var config = new AppConfiguration - { - OpcUa = new OpcUaConfiguration - { - Port = 14841, - GalaxyName = "TestGalaxy", - EndpointPath = "/LmxOpcUa" - }, - MxAccess = new MxAccessConfiguration - { - ClientName = "Test", - MonitorIntervalSeconds = 1, - AutoReconnect = true - }, - GalaxyRepository = new GalaxyRepositoryConfiguration(), - Dashboard = new DashboardConfiguration { Enabled = false } - }; - - var proxy = new FakeMxProxy { ShouldFailRegister = true }; - var repo = new FakeGalaxyRepository - { - Hierarchy = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false - } - }, - Attributes = new List - { - new() - { - GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", - FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false - } - } - }; - - var service = new OpcUaService(config, proxy, repo); - service.Start(); - - try - { - service.ServerHost.ShouldNotBeNull(); - service.MxClient.ShouldNotBeNull(); - service.MxClient!.State.ShouldBe(ConnectionState.Error); - - proxy.ShouldFailRegister = false; - await Task.Delay(2500); - - service.MxClient.State.ShouldBe(ConnectionState.Connected); - proxy.RegisterCallCount.ShouldBeGreaterThan(1); - } - finally - { - service.Stop(); - } - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ShutdownCompletesTest.cs b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ShutdownCompletesTest.cs deleted file mode 100644 index 89e282b..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/Wiring/ShutdownCompletesTest.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Host; -using ZB.MOM.WW.OtOpcUa.Host.Configuration; -using ZB.MOM.WW.OtOpcUa.Tests.Helpers; - -namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring -{ - /// - /// Verifies: Start then Stop completes within 30 seconds. (SVC-004) - /// - public class ShutdownCompletesTest - { - /// - /// Confirms that a started service can shut down within the required time budget. - /// - [Fact] - public void Shutdown_CompletesWithin30Seconds() - { - var config = new AppConfiguration - { - OpcUa = new OpcUaConfiguration { Port = 14841, GalaxyName = "TestGalaxy" }, - MxAccess = new MxAccessConfiguration { ClientName = "Test" }, - Dashboard = new DashboardConfiguration { Enabled = false } - }; - - var proxy = new FakeMxProxy(); - var repo = new FakeGalaxyRepository(); - var service = new OpcUaService(config, proxy, repo); - - service.Start(); - - var sw = Stopwatch.StartNew(); - service.Stop(); - sw.Stop(); - - sw.Elapsed.TotalSeconds.ShouldBeLessThan(30); - } - } -} \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj b/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj deleted file mode 100644 index a311596..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj +++ /dev/null @@ -1,56 +0,0 @@ - - - - net48 - x86 - 9.0 - enable - false - true - ZB.MOM.WW.OtOpcUa.Tests - - ZB.MOM.WW.OtOpcUa.Tests - - false - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MxAccess.dll - false - - - - - - PreserveNewest - - - - -- 2.49.1