From 72e7bbe968c105d4e8e0566d666c97c8f6ff76ae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 13 May 2026 13:29:58 -0400 Subject: [PATCH] chore: remove deprecated LmxProxy reference implementation Delete the standalone ZB.MOM.WW.LmxProxy solution and loose adapter stubs under deprecated/, plus the lone LmxProxy mention in deprecated/windev.md. The protocol was never wired into the active codebase and the runtime artifact has been removed from the cluster. --- deprecated/2026-03-19-lmxfakeproxy-design.md | 228 -- .../2026-03-19-lmxfakeproxy-implementation.md | 1842 --------- ...-lmxfakeproxy-implementation.md.tasks.json | 16 - deprecated/ILmxProxyClient.cs | 43 - deprecated/LmxProxyDataConnection.cs | 314 -- deprecated/LmxProxyDataConnectionTests.cs | 309 -- deprecated/RealLmxProxyClient.cs | 92 - deprecated/docs-lmxproxy_protocol.md | 388 -- deprecated/lmxproxy/.gitignore | 1 - deprecated/lmxproxy/CLAUDE.md | 71 - deprecated/lmxproxy/ZB.MOM.WW.LmxProxy.slnx | 11 - deprecated/lmxproxy/docs/deviations.md | 107 - deprecated/lmxproxy/docs/lmxproxy_protocol.md | 360 -- deprecated/lmxproxy/docs/lmxproxy_updates.md | 646 --- .../2026-03-21-lmxproxy-v2-rebuild-design.md | 210 - ...03-22-gap1-gap2-reconnect-subscriptions.md | 673 --- ...gap2-reconnect-subscriptions.md.tasks.json | 15 - .../docs/plans/lmxproxy-stale-session-fix.md | 185 - .../plans/phase-1-protocol-domain-types.md | 2723 ------------- .../lmxproxy/docs/plans/phase-2-host-core.md | 2067 ---------- .../phase-3-host-grpc-security-config.md | 1799 -------- .../docs/plans/phase-4-host-health-metrics.md | 666 --- .../docs/plans/phase-5-client-core.md | 852 ---- .../docs/plans/phase-6-client-extras.md | 815 ---- .../plans/phase-7-integration-deployment.md | 837 ---- .../docs/requirements/Component-Client.md | 200 - .../requirements/Component-Configuration.md | 122 - .../docs/requirements/Component-GrpcServer.md | 86 - .../Component-HealthAndMetrics.md | 121 - .../requirements/Component-MxAccessClient.md | 108 - .../docs/requirements/Component-Protocol.md | 301 -- .../docs/requirements/Component-Security.md | 119 - .../requirements/Component-ServiceHost.md | 108 - .../requirements/Component-SessionManager.md | 76 - .../Component-SubscriptionManager.md | 116 - .../docs/requirements/HighLevelReqs.md | 274 -- deprecated/lmxproxy/docs/sta_gap.md | 167 - deprecated/lmxproxy/instances_config.md | 95 - .../lmxproxy/lib/ArchestrA.MXAccess.dll | Bin 23112 -> 0 bytes deprecated/lmxproxy/mxaccess_documentation.md | 3619 ----------------- .../ClientTlsConfiguration.cs | 48 - .../Domain/ConnectionState.cs | 49 - .../Domain/Quality.cs | 118 - .../Domain/QualityExtensions.cs | 8 - .../Domain/ScadaContracts.cs | 444 -- .../ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs | 27 - .../ILmxProxyClient.cs | 77 - .../ILmxProxyClientFactory.cs | 150 - .../LmxProxyClient.ApiKeyInfo.cs | 36 - .../LmxProxyClient.ClientMetrics.cs | 100 - .../LmxProxyClient.CodeFirstSubscription.cs | 156 - .../LmxProxyClient.Connection.cs | 262 -- .../LmxProxyClient.ISubscription.cs | 16 - .../LmxProxyClient.cs | 573 --- .../LmxProxyClientBuilder.cs | 241 -- .../Properties/AssemblyInfo.cs | 4 - .../Security/GrpcChannelFactory.cs | 184 - .../ServiceCollectionExtensions.cs | 182 - .../StreamingExtensions.cs | 260 -- .../ZB.MOM.WW.LmxProxy.Client.csproj | 27 - .../ZB.MOM.WW.LmxProxy.Host/App.config | 25 - .../Configuration/ConfigurationValidator.cs | 206 - .../Configuration/LmxProxyConfiguration.cs | 110 - .../ServiceRecoveryConfiguration.cs | 28 - .../SubscriptionConfiguration.cs | 18 - .../Configuration/TlsConfiguration.cs | 90 - .../Domain/ClientStats.cs | 23 - .../Domain/ConnectionState.cs | 38 - .../Domain/ConnectionStateChangedEventArgs.cs | 45 - .../Domain/IScadaClient.cs | 104 - .../ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs | 124 - .../Domain/SubscriptionStats.cs | 30 - .../ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs | 129 - .../Grpc/Protos/scada.proto | 166 - .../Grpc/Services/ScadaGrpcService.cs | 804 ---- .../MxAccessClient.Connection.cs | 298 -- .../MxAccessClient.EventHandlers.cs | 166 - .../MxAccessClient.NestedTypes.cs | 132 - .../MxAccessClient.ReadWrite.cs | 402 -- .../MxAccessClient.Subscription.cs | 153 - .../Implementation/MxAccessClient.cs | 136 - .../LmxProxyService.cs | 592 --- .../ZB.MOM.WW.LmxProxy.Host/Program.cs | 87 - .../Security/ApiKey.cs | 49 - .../Security/ApiKeyConfiguration.cs | 15 - .../Security/ApiKeyInterceptor.cs | 168 - .../Security/ApiKeyService.cs | 305 -- .../Security/TlsCertificateManager.cs | 329 -- .../Services/HealthCheckService.cs | 189 - .../Services/PerformanceMetrics.cs | 213 - .../Services/RetryPolicies.cs | 193 - .../Services/SessionManager.cs | 182 - .../Services/StatusReportService.cs | 433 -- .../Services/StatusWebServer.cs | 315 -- .../Services/SubscriptionManager.cs | 535 --- .../ZB.MOM.WW.LmxProxy.Host.csproj | 65 - .../appsettings.Production.json | 40 - .../ZB.MOM.WW.LmxProxy.Host/appsettings.json | 84 - .../appsettings.tls.json | 52 - .../ClientConfiguration.cs | 19 - .../ClientTlsConfiguration.cs | 31 - .../Domain/ConnectionState.cs | 12 - .../Domain/Quality.cs | 51 - .../Domain/QualityExtensions.cs | 29 - .../Domain/ScadaContracts.cs | 499 --- .../ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs | 12 - .../ILmxProxyClient.cs | 58 - .../ILmxProxyClientFactory.cs | 81 - .../LmxProxyClient.ApiKeyInfo.cs | 19 - .../LmxProxyClient.ClientMetrics.cs | 82 - .../LmxProxyClient.CodeFirstSubscription.cs | 127 - .../LmxProxyClient.Connection.cs | 219 - .../LmxProxyClient.ISubscription.cs | 13 - .../LmxProxyClient.cs | 332 -- .../LmxProxyClientBuilder.cs | 157 - .../LmxProxyClientOptions.cs | 46 - .../Security/GrpcChannelFactory.cs | 131 - .../ServiceCollectionExtensions.cs | 71 - .../StreamingExtensions.cs | 218 - .../ZB.MOM.WW.LmxProxy.Client.csproj | 32 - .../Configuration/ConfigurationValidator.cs | 104 - .../Configuration/ConnectionConfiguration.cs | 30 - .../Configuration/LmxProxyConfiguration.cs | 46 - .../ServiceRecoveryConfiguration.cs | 18 - .../SubscriptionConfiguration.cs | 12 - .../Configuration/TlsConfiguration.cs | 24 - .../Configuration/WebServerConfiguration.cs | 15 - .../Domain/ConnectionState.cs | 15 - .../Domain/ConnectionStateChangedEventArgs.cs | 24 - .../Domain/IScadaClient.cs | 79 - .../Domain/MxStatusMapper.cs | 186 - .../Domain/ProbeResult.cs | 39 - .../ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs | 127 - .../Domain/QualityCodeMapper.cs | 167 - .../Domain/QualityExtensions.cs | 17 - .../Domain/SubscriptionStats.cs | 22 - .../Domain/TypedValueComparer.cs | 35 - .../Domain/TypedValueConverter.cs | 229 -- .../src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs | 54 - .../Grpc/Protos/scada.proto | 214 - .../Grpc/Services/ScadaGrpcService.cs | 466 --- .../Health/HealthCheckService.cs | 99 - .../LmxProxyService.cs | 235 -- .../Metrics/PerformanceMetrics.cs | 205 - .../MxAccess/MxAccessClient.Connection.cs | 332 -- .../MxAccess/MxAccessClient.EventHandlers.cs | 145 - .../MxAccess/MxAccessClient.ReadWrite.cs | 295 -- .../MxAccess/MxAccessClient.Subscription.cs | 222 - .../MxAccess/MxAccessClient.cs | 164 - .../MxAccess/StaComThread.cs | 247 -- .../src/ZB.MOM.WW.LmxProxy.Host/Program.cs | 83 - .../Security/ApiKey.cs | 20 - .../Security/ApiKeyConfiguration.cs | 10 - .../Security/ApiKeyInterceptor.cs | 84 - .../Security/ApiKeyService.cs | 183 - .../Security/TlsCertificateManager.cs | 56 - .../Sessions/SessionManager.cs | 173 - .../Status/StatusModels.cs | 60 - .../Status/StatusReportService.cs | 289 -- .../Status/StatusWebServer.cs | 215 - .../Subscriptions/SubscriptionManager.cs | 361 -- .../ZB.MOM.WW.LmxProxy.Host.csproj | 64 - .../ZB.MOM.WW.LmxProxy.Host/appsettings.json | 86 - .../appsettings.v2.json | 6 - .../appsettings.v2b.json | 6 - .../CheckApiKeyTests.cs | 25 - .../ConnectionTests.cs | 29 - .../GlobalUsings.cs | 2 - .../IntegrationTestBase.cs | 56 - .../ReadTests.cs | 61 - .../SubscribeTests.cs | 34 - .../WriteBatchAndWaitTests.cs | 26 - .../WriteTests.cs | 30 - ...WW.LmxProxy.Client.IntegrationTests.csproj | 30 - .../appsettings.test.json | 9 - .../xunit.runner.json | 5 - .../ClientMetricsTests.cs | 122 - .../CrossStackSerializationTests.cs | 270 -- .../Domain/QualityExtensionsTests.cs | 29 - .../Domain/ScadaContractsTests.cs | 134 - .../Domain/VtqTests.cs | 33 - .../Fakes/FakeLmxProxyClient.cs | 91 - .../Fakes/FakeScadaService.cs | 112 - .../Fakes/TestableClient.cs | 50 - .../LmxProxyClientBuilderTests.cs | 106 - .../LmxProxyClientConnectionTests.cs | 103 - .../LmxProxyClientFactoryTests.cs | 51 - .../LmxProxyClientReadWriteTests.cs | 177 - .../LmxProxyClientSubscriptionTests.cs | 100 - .../ServiceCollectionExtensionsTests.cs | 92 - .../StreamingExtensionsTests.cs | 157 - .../TypedValueConversionTests.cs | 157 - .../ZB.MOM.WW.LmxProxy.Client.Tests.csproj | 39 - .../ConfigurationValidatorTests.cs | 77 - .../Domain/QualityCodeMapperTests.cs | 87 - .../Domain/QualityExtensionsTests.cs | 39 - .../Domain/TypedValueConverterTests.cs | 196 - .../Health/HealthCheckServiceTests.cs | 137 - .../Metrics/PerformanceMetricsTests.cs | 147 - .../MxAccess/TypedValueEqualsTests.cs | 78 - .../Security/ApiKeyInterceptorTests.cs | 46 - .../Security/ApiKeyServiceTests.cs | 118 - .../Sessions/SessionManagerTests.cs | 148 - .../Status/StatusReportServiceTests.cs | 132 - .../Subscriptions/SubscriptionManagerTests.cs | 197 - .../ZB.MOM.WW.LmxProxy.Host.Tests.csproj | 29 - deprecated/windev.md | 4 - 207 files changed, 42184 deletions(-) delete mode 100644 deprecated/2026-03-19-lmxfakeproxy-design.md delete mode 100644 deprecated/2026-03-19-lmxfakeproxy-implementation.md delete mode 100644 deprecated/2026-03-19-lmxfakeproxy-implementation.md.tasks.json delete mode 100644 deprecated/ILmxProxyClient.cs delete mode 100644 deprecated/LmxProxyDataConnection.cs delete mode 100644 deprecated/LmxProxyDataConnectionTests.cs delete mode 100644 deprecated/RealLmxProxyClient.cs delete mode 100644 deprecated/docs-lmxproxy_protocol.md delete mode 100644 deprecated/lmxproxy/.gitignore delete mode 100644 deprecated/lmxproxy/CLAUDE.md delete mode 100644 deprecated/lmxproxy/ZB.MOM.WW.LmxProxy.slnx delete mode 100644 deprecated/lmxproxy/docs/deviations.md delete mode 100644 deprecated/lmxproxy/docs/lmxproxy_protocol.md delete mode 100644 deprecated/lmxproxy/docs/lmxproxy_updates.md delete mode 100644 deprecated/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md delete mode 100644 deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md delete mode 100644 deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md.tasks.json delete mode 100644 deprecated/lmxproxy/docs/plans/lmxproxy-stale-session-fix.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-1-protocol-domain-types.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-2-host-core.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-4-host-health-metrics.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-5-client-core.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-6-client-extras.md delete mode 100644 deprecated/lmxproxy/docs/plans/phase-7-integration-deployment.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-Client.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-Configuration.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-GrpcServer.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-HealthAndMetrics.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-MxAccessClient.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-Protocol.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-Security.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-ServiceHost.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-SessionManager.md delete mode 100644 deprecated/lmxproxy/docs/requirements/Component-SubscriptionManager.md delete mode 100644 deprecated/lmxproxy/docs/requirements/HighLevelReqs.md delete mode 100644 deprecated/lmxproxy/docs/sta_gap.md delete mode 100644 deprecated/lmxproxy/instances_config.md delete mode 100755 deprecated/lmxproxy/lib/ArchestrA.MXAccess.dll delete mode 100755 deprecated/lmxproxy/mxaccess_documentation.md delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json delete mode 100644 deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientOptions.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/MxStatusMapper.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2.json delete mode 100644 deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2b.json delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/xunit.runner.json delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeLmxProxyClient.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeScadaService.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/TestableClient.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ServiceCollectionExtensionsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs delete mode 100644 deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj diff --git a/deprecated/2026-03-19-lmxfakeproxy-design.md b/deprecated/2026-03-19-lmxfakeproxy-design.md deleted file mode 100644 index 2a7ae57..0000000 --- a/deprecated/2026-03-19-lmxfakeproxy-design.md +++ /dev/null @@ -1,228 +0,0 @@ -# LmxFakeProxy: OPC UA-Backed Test Proxy for LmxProxy Protocol - -**Date:** 2026-03-19 -**Status:** Approved - -## Purpose - -Create a test-infrastructure gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the existing OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of the `RealLmxProxyClient` and the LmxProxy DCL adapter against real data without requiring a Windows-hosted LmxProxy deployment. - -## Architecture - -``` -┌─────────────────────┐ gRPC (50051) ┌──────────────────┐ OPC UA (50000) ┌─────────────────┐ -│ RealLmxProxyClient │ ◄──────────────────────► │ LmxFakeProxy │ ◄───────────────────► │ OPC PLC Server │ -│ (ScadaLink DCL) │ scada.ScadaService │ (infra service) │ OPC Foundation SDK │ (Docker) │ -└─────────────────────┘ └──────────────────┘ └─────────────────┘ -``` - -- Full proto parity: implements every RPC in `scada.proto` -- Configurable OPC UA endpoint prefix (`--opc-prefix`, default `ns=3;s=`) -- Optional API key enforcement (`--api-key`, default accept-all) -- Full session tracking with validation -- Native OPC UA MonitoredItems for subscription streaming -- OPC UA reconnection with bad-quality push on disconnect -- Runs as Docker service (port 50051) or standalone via `dotnet run` - -## Tag Address Mapping - -Configurable prefix prepend. Default maps LMX flat addresses to OPC PLC namespace 3: - -| LMX Tag | OPC UA NodeId | -|---------|--------------| -| `Motor.Speed` | `ns=3;s=Motor.Speed` | -| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` | -| `Tank.HighLevel` | `ns=3;s=Tank.HighLevel` | - -Mapping: `opcNodeId = $"{prefix}{lmxTag}"` - -**Value conversions:** -- OPC UA value → VtqMessage: `ToString()` for value, `DateTime.UtcNow.Ticks` for timestamp, StatusCode mapped to `"Good"` / `"Uncertain"` / `"Bad"` -- Write value parsing (string → typed): attempt `double` → `bool` → `uint` → fall back to `string` -- Quality mapping: StatusCode 0 = Good, high bit set = Bad, else Uncertain - -## gRPC Service Implementation - -### Connection Management -- **Connect** — Validate API key (if configured), generate Guid session ID, store in `ConcurrentDictionary`. Return success + session ID. -- **Disconnect** — Remove session. No-op for unknown sessions. -- **GetConnectionState** — Look up session, return connection info. Return `is_connected=false` for unknown sessions. -- **CheckApiKey** — Return `is_valid=true` if no key configured or key matches. - -### Read Operations -- **Read** — Validate session, map tag to OPC UA NodeId, read via OPC UA client, return VtqMessage. -- **ReadBatch** — Same for multiple tags, sequential reads. - -### Write Operations -- **Write** — Validate session, parse string value to typed, write via OPC UA. -- **WriteBatch** — Write each item, collect per-item results. -- **WriteBatchAndWait** — Write all items, poll `flag_tag` at `poll_interval_ms` until match or timeout. - -### Subscription -- **Subscribe** — Validate session, create OPC UA MonitoredItems for each tag with `sampling_ms` as the OPC UA SamplingInterval. Stream VtqMessage on each data change notification. Stream stays open until client cancels. On cancellation, remove monitored items. - -### Error Handling -- Invalid session → `success=false`, `message="Invalid or expired session"` -- OPC UA failure → `success=false` with status code in message -- OPC UA disconnected → active streams get Bad quality push then close, RPCs return failure - -## OPC UA Client Bridge - -Single shared OPC UA session to the backend server, reused across all gRPC client sessions. - -**`OpcUaBridge` class (behind `IOpcUaBridge` interface):** -- `ConnectAsync()` — Establish OPC UA session (always `MessageSecurityMode.None`, auto-accept certs) -- `ReadAsync(nodeId)` — Single node read -- `WriteAsync(nodeId, value)` — Single node write -- `AddMonitoredItems(nodeIds, samplingMs, callback)` — Add to shared subscription -- `RemoveMonitoredItems(handles)` — Remove from shared subscription - -**Reconnection:** -- Detect disconnection via `Session.KeepAlive` event -- On disconnect: set `_connected = false`, push Bad quality VtqMessage to all active subscription streams, close streams -- Background reconnect loop at 5-second fixed interval -- On reconnection: re-create subscription, re-add monitored items for still-active gRPC streams -- RPCs while disconnected return `success=false, "OPC UA backend unavailable"` - -**Single session rationale:** OPC PLC is local/lightweight, mirrors how real LmxProxy shares MXAccess, simpler lifecycle. - -## API Key Authentication - -Accept-any by default, optional enforcement: -- If `--api-key` is not set, all requests are accepted regardless of key -- If `--api-key` is set, the `x-api-key` gRPC metadata header must match on every call -- Validation happens in a gRPC interceptor (mirrors the real LmxProxy's `ApiKeyInterceptor`) - -## Project Structure - -``` -infra/lmxfakeproxy/ -├── LmxFakeProxy.csproj -├── Program.cs # Host builder, CLI args / env vars, Kestrel on 50051 -├── Services/ -│ └── ScadaServiceImpl.cs # gRPC service implementation -├── Bridge/ -│ └── OpcUaBridge.cs # IOpcUaBridge + implementation -├── Sessions/ -│ └── SessionManager.cs # ConcurrentDictionary session tracking -├── Protos/ -│ └── scada.proto # Copied from DCL (generates server stubs) -├── Dockerfile # Multi-stage SDK → runtime -├── README.md -└── tests/ - └── LmxFakeProxy.Tests/ - ├── LmxFakeProxy.Tests.csproj - ├── SessionManagerTests.cs - ├── TagMappingTests.cs - └── ScadaServiceTests.cs -``` - -**NuGet dependencies:** -- `Grpc.AspNetCore` — gRPC server hosting -- `OPCFoundation.NetStandard.Opc.Ua.Client` — OPC UA SDK -- `Microsoft.Extensions.Hosting` — generic host -- Tests: `xunit`, `NSubstitute`, `Grpc.Net.Client` - -**CLI arguments / environment variables:** -| Arg | Env Var | Default | -|-----|---------|---------| -| `--port` | `PORT` | `50051` | -| `--opc-endpoint` | `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | -| `--opc-prefix` | `OPC_PREFIX` | `ns=3;s=` | -| `--api-key` | `API_KEY` | *(none — accept all)* | - -Env vars take precedence over CLI args. - -## Docker & Infrastructure Integration - -**docker-compose.yml addition:** -```yaml -lmxfakeproxy: - build: ./lmxfakeproxy - container_name: scadalink-lmxfakeproxy - ports: - - "50051:50051" - environment: - OPC_ENDPOINT: "opc.tcp://opcua:50000" - OPC_PREFIX: "ns=3;s=" - depends_on: - - opcua - networks: - - scadalink-net - restart: unless-stopped -``` - -**Dockerfile (multi-stage):** -```dockerfile -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY . . -RUN dotnet publish -c Release -o /app - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 -WORKDIR /app -COPY --from=build /app . -EXPOSE 50051 -ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"] -``` - -**Documentation updates:** -- `docs/test_infra/test_infra.md` — Add LmxFakeProxy to services table (6th service) -- `infra/README.md` — Add to quick-start table -- New `docs/test_infra/test_infra_lmxfakeproxy.md` — Dedicated per-service doc -- `docs/requirements/Component-DataConnectionLayer.md` — Note fake proxy availability for LmxProxy testing - -## Unit Tests - -### SessionManagerTests.cs -- `Connect_ReturnsUniqueSessionId` -- `Connect_WithValidApiKey_Succeeds` -- `Connect_WithInvalidApiKey_Fails` -- `Connect_WithNoKeyConfigured_AcceptsAnyKey` -- `Disconnect_RemovesSession` -- `Disconnect_UnknownSession_ReturnsFalse` -- `ValidateSession_ValidId_ReturnsTrue` -- `ValidateSession_InvalidId_ReturnsFalse` -- `GetConnectionState_ReturnsCorrectInfo` -- `GetConnectionState_UnknownSession_ReturnsNotConnected` - -### TagMappingTests.cs -- `ToOpcNodeId_PrependsPrefix` -- `ToOpcNodeId_CustomPrefix` -- `ToOpcNodeId_EmptyPrefix_PassesThrough` -- `ConvertWriteValue_ParsesDouble` -- `ConvertWriteValue_ParsesBool` -- `ConvertWriteValue_ParsesUint` -- `ConvertWriteValue_FallsBackToString` -- `MapStatusCode_Good_ReturnsGood` -- `MapStatusCode_Bad_ReturnsBad` -- `MapStatusCode_Uncertain_ReturnsUncertain` -- `ToVtqMessage_ConvertsCorrectly` - -### ScadaServiceTests.cs (mocked IOpcUaBridge) -- `Read_ValidSession_ReturnsVtq` -- `Read_InvalidSession_ReturnsFailure` -- `ReadBatch_ReturnsAllTags` -- `Write_ValidSession_Succeeds` -- `Write_InvalidSession_ReturnsFailure` -- `WriteBatch_ReturnsPerItemResults` -- `Subscribe_StreamsUpdatesUntilCancelled` -- `Subscribe_InvalidSession_ThrowsRpcException` -- `CheckApiKey_Valid_ReturnsTrue` -- `CheckApiKey_Invalid_ReturnsFalse` - -## Verification - -```bash -# Unit tests -cd infra/lmxfakeproxy -dotnet test tests/LmxFakeProxy.Tests/ - -# Docker build -cd infra -docker compose build lmxfakeproxy -docker compose up -d lmxfakeproxy - -# Integration smoke test (using RealLmxProxyClient from ScadaLink) -# Connect, read Motor.Speed, write Motor.Speed=42.0, read back, subscribe -``` diff --git a/deprecated/2026-03-19-lmxfakeproxy-implementation.md b/deprecated/2026-03-19-lmxfakeproxy-implementation.md deleted file mode 100644 index 427a8ff..0000000 --- a/deprecated/2026-03-19-lmxfakeproxy-implementation.md +++ /dev/null @@ -1,1842 +0,0 @@ -# LmxFakeProxy Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. - -**Goal:** Build a .NET 10 gRPC server (`infra/lmxfakeproxy/`) that implements the `scada.ScadaService` proto and bridges to the OPC UA test server, enabling end-to-end testing of `RealLmxProxyClient`. - -**Architecture:** A standalone ASP.NET Core gRPC server with three core components: `SessionManager` (ConcurrentDictionary-based session tracking), `OpcUaBridge` (shared OPC UA session with reconnection), and `ScadaServiceImpl` (gRPC service mapping proto RPCs to the bridge). The server runs on port 50051, maps LMX-style tag addresses to OPC UA NodeIds via a configurable prefix, and optionally enforces API key auth via a gRPC interceptor. - -**Tech Stack:** .NET 10, Grpc.AspNetCore, OPCFoundation.NetStandard.Opc.Ua.Client, xunit, NSubstitute - -**Design doc:** `docs/plans/2026-03-19-lmxfakeproxy-design.md` - ---- - -### Task 1: Project Scaffolding - -**Files:** -- Create: `infra/lmxfakeproxy/LmxFakeProxy.csproj` -- Create: `infra/lmxfakeproxy/Program.cs` (minimal, just enough to build) -- Create: `infra/lmxfakeproxy/Protos/scada.proto` (copy from DCL) -- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj` - -**Step 1: Create the project directory structure** - -```bash -mkdir -p infra/lmxfakeproxy/Services infra/lmxfakeproxy/Bridge infra/lmxfakeproxy/Sessions infra/lmxfakeproxy/Protos -mkdir -p infra/lmxfakeproxy/tests/LmxFakeProxy.Tests -``` - -**Step 2: Create LmxFakeProxy.csproj** - -```xml - - - net10.0 - LmxFakeProxy - enable - enable - - - - - - - - - - - -``` - -**Step 3: Copy scada.proto and change the namespace** - -Copy `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` to `infra/lmxfakeproxy/Protos/scada.proto`. Change the `csharp_namespace` option to: - -```protobuf -option csharp_namespace = "LmxFakeProxy.Grpc"; -``` - -Everything else in the proto stays the same — same package, same service, same messages. - -**Step 4: Create minimal Program.cs** - -```csharp -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddGrpc(); -var app = builder.Build(); -app.MapGet("/", () => "LmxFakeProxy is running"); -app.Run(); -``` - -**Step 5: Create test project csproj** - -```xml - - - net10.0 - LmxFakeProxy.Tests - enable - enable - false - - - - - - - - - - - - - -``` - -**Step 6: Verify build** - -```bash -cd infra/lmxfakeproxy && dotnet build -cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/ -``` - -Expected: Both build with 0 errors. The proto generates server-side stubs in `LmxFakeProxy.Grpc` namespace. - -**Step 7: Commit** - -```bash -git add infra/lmxfakeproxy/ -git commit -m "feat(infra): scaffold LmxFakeProxy project with proto and test project" -``` - ---- - -### Task 2: TagMapper Utility + Tests - -**Files:** -- Create: `infra/lmxfakeproxy/TagMapper.cs` -- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs` - -**Step 1: Write the failing tests** - -Create `tests/LmxFakeProxy.Tests/TagMappingTests.cs`: - -```csharp -namespace LmxFakeProxy.Tests; - -public class TagMappingTests -{ - [Fact] - public void ToOpcNodeId_PrependsPrefix() - { - var mapper = new TagMapper("ns=3;s="); - Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed")); - } - - [Fact] - public void ToOpcNodeId_CustomPrefix() - { - var mapper = new TagMapper("ns=2;s=MyFolder."); - Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure")); - } - - [Fact] - public void ToOpcNodeId_EmptyPrefix_PassesThrough() - { - var mapper = new TagMapper(""); - Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed")); - } - - [Fact] - public void ParseWriteValue_Double() - { - Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5")); - Assert.IsType(TagMapper.ParseWriteValue("42.5")); - } - - [Fact] - public void ParseWriteValue_Bool() - { - Assert.Equal(true, TagMapper.ParseWriteValue("true")); - Assert.Equal(false, TagMapper.ParseWriteValue("False")); - } - - [Fact] - public void ParseWriteValue_Uint() - { - // "100" parses as double first (double.TryParse succeeds for integers) - // So uint only hits for values that look like uint but not double — not realistic. - // Actually, double.TryParse("100") succeeds, so this returns 100.0 (double). - // That's fine — OPC UA accepts double writes to UInt32 nodes. - var result = TagMapper.ParseWriteValue("100"); - Assert.IsType(result); - } - - [Fact] - public void ParseWriteValue_FallsBackToString() - { - Assert.Equal("hello", TagMapper.ParseWriteValue("hello")); - Assert.IsType(TagMapper.ParseWriteValue("hello")); - } - - [Fact] - public void MapStatusCode_Good() - { - Assert.Equal("Good", TagMapper.MapQuality(0)); - } - - [Fact] - public void MapStatusCode_Bad() - { - Assert.Equal("Bad", TagMapper.MapQuality(0x80000000)); - } - - [Fact] - public void MapStatusCode_Uncertain() - { - Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000)); - } - - [Fact] - public void ToVtqMessage_ConvertsCorrectly() - { - var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0); - Assert.Equal("Motor.Speed", vtq.Tag); - Assert.Equal("42.5", vtq.Value); - Assert.Equal("Good", vtq.Quality); - Assert.True(vtq.TimestampUtcTicks > 0); - } -} -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: FAIL — `TagMapper` class does not exist. - -**Step 3: Implement TagMapper** - -Create `infra/lmxfakeproxy/TagMapper.cs`: - -```csharp -using LmxFakeProxy.Grpc; - -namespace LmxFakeProxy; - -public class TagMapper -{ - private readonly string _prefix; - - public TagMapper(string prefix) - { - _prefix = prefix; - } - - public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}"; - - public static object ParseWriteValue(string value) - { - if (double.TryParse(value, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var d)) - return d; - if (bool.TryParse(value, out var b)) - return b; - return value; - } - - public static string MapQuality(uint statusCode) - { - if (statusCode == 0) return "Good"; - if ((statusCode & 0x80000000) != 0) return "Bad"; - return "Uncertain"; - } - - public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode) - { - return new VtqMessage - { - Tag = tag, - Value = value?.ToString() ?? string.Empty, - TimestampUtcTicks = timestampUtc.Ticks, - Quality = MapQuality(statusCode) - }; - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: All 11 tests PASS. - -**Step 5: Commit** - -```bash -git add infra/lmxfakeproxy/TagMapper.cs infra/lmxfakeproxy/tests/ -git commit -m "feat(infra): add TagMapper with address mapping, value parsing, and quality mapping" -``` - ---- - -### Task 3: SessionManager + Tests - -**Files:** -- Create: `infra/lmxfakeproxy/Sessions/SessionManager.cs` -- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs` - -**Step 1: Write the failing tests** - -Create `tests/LmxFakeProxy.Tests/SessionManagerTests.cs`: - -```csharp -namespace LmxFakeProxy.Tests; - -using LmxFakeProxy.Sessions; - -public class SessionManagerTests -{ - [Fact] - public void Connect_ReturnsUniqueSessionId() - { - var mgr = new SessionManager(null); - var (ok1, _, id1) = mgr.Connect("client1", ""); - var (ok2, _, id2) = mgr.Connect("client2", ""); - Assert.True(ok1); - Assert.True(ok2); - Assert.NotEqual(id1, id2); - } - - [Fact] - public void Connect_WithValidApiKey_Succeeds() - { - var mgr = new SessionManager("secret"); - var (ok, _, _) = mgr.Connect("client1", "secret"); - Assert.True(ok); - } - - [Fact] - public void Connect_WithInvalidApiKey_Fails() - { - var mgr = new SessionManager("secret"); - var (ok, msg, id) = mgr.Connect("client1", "wrong"); - Assert.False(ok); - Assert.Empty(id); - Assert.Contains("Invalid API key", msg); - } - - [Fact] - public void Connect_WithNoKeyConfigured_AcceptsAnyKey() - { - var mgr = new SessionManager(null); - var (ok1, _, _) = mgr.Connect("c1", "anykey"); - var (ok2, _, _) = mgr.Connect("c2", ""); - Assert.True(ok1); - Assert.True(ok2); - } - - [Fact] - public void Disconnect_RemovesSession() - { - var mgr = new SessionManager(null); - var (_, _, id) = mgr.Connect("client1", ""); - Assert.True(mgr.ValidateSession(id)); - var ok = mgr.Disconnect(id); - Assert.True(ok); - Assert.False(mgr.ValidateSession(id)); - } - - [Fact] - public void Disconnect_UnknownSession_ReturnsFalse() - { - var mgr = new SessionManager(null); - Assert.False(mgr.Disconnect("nonexistent")); - } - - [Fact] - public void ValidateSession_ValidId_ReturnsTrue() - { - var mgr = new SessionManager(null); - var (_, _, id) = mgr.Connect("client1", ""); - Assert.True(mgr.ValidateSession(id)); - } - - [Fact] - public void ValidateSession_InvalidId_ReturnsFalse() - { - var mgr = new SessionManager(null); - Assert.False(mgr.ValidateSession("bogus")); - } - - [Fact] - public void GetConnectionState_ReturnsCorrectInfo() - { - var mgr = new SessionManager(null); - var (_, _, id) = mgr.Connect("myClient", ""); - var (found, clientId, ticks) = mgr.GetConnectionState(id); - Assert.True(found); - Assert.Equal("myClient", clientId); - Assert.True(ticks > 0); - } - - [Fact] - public void GetConnectionState_UnknownSession_ReturnsNotConnected() - { - var mgr = new SessionManager(null); - var (found, clientId, ticks) = mgr.GetConnectionState("unknown"); - Assert.False(found); - Assert.Empty(clientId); - Assert.Equal(0, ticks); - } - - [Fact] - public void CheckApiKey_NoKeyConfigured_AlwaysValid() - { - var mgr = new SessionManager(null); - Assert.True(mgr.CheckApiKey("anything")); - Assert.True(mgr.CheckApiKey("")); - } - - [Fact] - public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly() - { - var mgr = new SessionManager("mykey"); - Assert.True(mgr.CheckApiKey("mykey")); - Assert.False(mgr.CheckApiKey("wrong")); - Assert.False(mgr.CheckApiKey("")); - } -} -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: FAIL — `SessionManager` does not exist. - -**Step 3: Implement SessionManager** - -Create `infra/lmxfakeproxy/Sessions/SessionManager.cs`: - -```csharp -using System.Collections.Concurrent; - -namespace LmxFakeProxy.Sessions; - -public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks); - -public class SessionManager -{ - private readonly string? _requiredApiKey; - private readonly ConcurrentDictionary _sessions = new(); - - public SessionManager(string? requiredApiKey) - { - _requiredApiKey = requiredApiKey; - } - - public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey) - { - if (!CheckApiKey(apiKey)) - return (false, "Invalid API key", string.Empty); - - var sessionId = Guid.NewGuid().ToString("N"); - var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks); - _sessions[sessionId] = info; - return (true, "Connected", sessionId); - } - - public bool Disconnect(string sessionId) - { - return _sessions.TryRemove(sessionId, out _); - } - - public bool ValidateSession(string sessionId) - { - return _sessions.ContainsKey(sessionId); - } - - public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId) - { - if (_sessions.TryGetValue(sessionId, out var info)) - return (true, info.ClientId, info.ConnectedSinceUtcTicks); - return (false, string.Empty, 0); - } - - public bool CheckApiKey(string apiKey) - { - if (string.IsNullOrEmpty(_requiredApiKey)) - return true; - return apiKey == _requiredApiKey; - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: All 23 tests PASS (11 TagMapping + 12 SessionManager). - -**Step 5: Commit** - -```bash -git add infra/lmxfakeproxy/Sessions/ infra/lmxfakeproxy/tests/ -git commit -m "feat(infra): add SessionManager with full session tracking and API key validation" -``` - ---- - -### Task 4: IOpcUaBridge Interface + OpcUaBridge Implementation - -**Files:** -- Create: `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` -- Create: `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` - -**Step 1: Create the IOpcUaBridge interface** - -Create `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`: - -```csharp -namespace LmxFakeProxy.Bridge; - -public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode); - -public interface IOpcUaBridge : IAsyncDisposable -{ - bool IsConnected { get; } - - Task ConnectAsync(CancellationToken cancellationToken = default); - - Task ReadAsync(string nodeId, CancellationToken cancellationToken = default); - - Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default); - - /// - /// Add monitored items to the shared OPC UA subscription. - /// Returns a handle that can be used to remove them later. - /// The callback receives (nodeId, value, timestamp, statusCode) on each data change. - /// - Task AddMonitoredItemsAsync( - IEnumerable nodeIds, - int samplingIntervalMs, - Action onValueChanged, - CancellationToken cancellationToken = default); - - Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default); - - /// - /// Raised when the OPC UA backend becomes unreachable. - /// - event Action? Disconnected; - - /// - /// Raised when the OPC UA backend reconnects after a disconnection. - /// - event Action? Reconnected; -} -``` - -**Step 2: Implement OpcUaBridge** - -Create `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`. This mirrors `RealOpcUaClient` from the main project but adds reconnection logic and multi-client monitored item management: - -```csharp -using Opc.Ua; -using Opc.Ua.Client; -using Opc.Ua.Configuration; - -namespace LmxFakeProxy.Bridge; - -public class OpcUaBridge : IOpcUaBridge -{ - private readonly string _endpointUrl; - private readonly ILogger _logger; - private ISession? _session; - private Subscription? _subscription; - private volatile bool _connected; - private volatile bool _reconnecting; - private CancellationTokenSource? _reconnectCts; - - // Track monitored items per handle (one handle per Subscribe call) - private readonly Dictionary> _handleItems = new(); - private readonly Dictionary> _handleCallbacks = new(); - private readonly object _lock = new(); - - public OpcUaBridge(string endpointUrl, ILogger logger) - { - _endpointUrl = endpointUrl; - _logger = logger; - } - - public bool IsConnected => _connected; - public event Action? Disconnected; - public event Action? Reconnected; - - public async Task ConnectAsync(CancellationToken cancellationToken = default) - { - var appConfig = new ApplicationConfiguration - { - ApplicationName = "LmxFakeProxy", - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - AutoAcceptUntrustedCertificates = true, - ApplicationCertificate = new CertificateIdentifier(), - TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") }, - TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") }, - RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") } - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, - TransportQuotas = new TransportQuotas { OperationTimeout = 15000 } - }; - - await appConfig.ValidateAsync(ApplicationType.Client); - appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - - EndpointDescription? endpoint; - try - { -#pragma warning disable CS0618 - using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl)); - var endpoints = discoveryClient.GetEndpoints(null); -#pragma warning restore CS0618 - endpoint = endpoints - .Where(e => e.SecurityMode == MessageSecurityMode.None) - .FirstOrDefault() ?? endpoints.FirstOrDefault(); - } - catch - { - endpoint = new EndpointDescription(_endpointUrl); - } - - var endpointConfig = EndpointConfiguration.Create(appConfig); - var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); - -#pragma warning disable CS0618 - var sessionFactory = new DefaultSessionFactory(); -#pragma warning restore CS0618 - _session = await sessionFactory.CreateAsync( - appConfig, configuredEndpoint, false, - "LmxFakeProxy-Session", 60000, null, null, cancellationToken); - - _session.KeepAlive += OnSessionKeepAlive; - - _subscription = new Subscription(_session.DefaultSubscription) - { - DisplayName = "LmxFakeProxy", - PublishingEnabled = true, - PublishingInterval = 500, - KeepAliveCount = 10, - LifetimeCount = 30, - MaxNotificationsPerPublish = 1000 - }; - - _session.AddSubscription(_subscription); - await _subscription.CreateAsync(cancellationToken); - - _connected = true; - _logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl); - } - - public async Task ReadAsync(string nodeId, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }; - var response = await _session!.ReadAsync( - null, 0, TimestampsToReturn.Source, - new ReadValueIdCollection { readValue }, cancellationToken); - - var result = response.Results[0]; - return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code); - } - - public async Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var writeValue = new WriteValue - { - NodeId = nodeId, - AttributeId = Attributes.Value, - Value = new DataValue(new Variant(value)) - }; - - var response = await _session!.WriteAsync( - null, new WriteValueCollection { writeValue }, cancellationToken); - - return response.Results[0].Code; - } - - public async Task AddMonitoredItemsAsync( - IEnumerable nodeIds, - int samplingIntervalMs, - Action onValueChanged, - CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var handle = Guid.NewGuid().ToString("N"); - var items = new List(); - - foreach (var nodeId in nodeIds) - { - var monitoredItem = new MonitoredItem(_subscription!.DefaultItem) - { - DisplayName = nodeId, - StartNodeId = nodeId, - AttributeId = Attributes.Value, - SamplingInterval = samplingIntervalMs, - QueueSize = 10, - DiscardOldest = true - }; - - monitoredItem.Notification += (item, e) => - { - if (e.NotificationValue is MonitoredItemNotification notification) - { - var val = notification.Value?.Value; - var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow; - var sc = notification.Value?.StatusCode.Code ?? 0; - onValueChanged(nodeId, val, ts, sc); - } - }; - - items.Add(monitoredItem); - _subscription!.AddItem(monitoredItem); - } - - await _subscription!.ApplyChangesAsync(cancellationToken); - - lock (_lock) - { - _handleItems[handle] = items; - _handleCallbacks[handle] = onValueChanged; - } - - return handle; - } - - public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default) - { - List? items; - lock (_lock) - { - if (!_handleItems.Remove(handle, out items)) - return; - _handleCallbacks.Remove(handle); - } - - if (_subscription != null) - { - foreach (var item in items) - _subscription.RemoveItem(item); - - try { await _subscription.ApplyChangesAsync(cancellationToken); } - catch { /* best-effort during cleanup */ } - } - } - - private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e) - { - if (ServiceResult.IsBad(e.Status)) - { - if (!_connected) return; - _connected = false; - _logger.LogWarning("OPC UA backend connection lost"); - Disconnected?.Invoke(); - StartReconnectLoop(); - } - } - - private void StartReconnectLoop() - { - if (_reconnecting) return; - _reconnecting = true; - _reconnectCts = new CancellationTokenSource(); - - _ = Task.Run(async () => - { - while (!_reconnectCts.Token.IsCancellationRequested) - { - await Task.Delay(5000, _reconnectCts.Token); - try - { - _logger.LogInformation("Attempting OPC UA reconnection..."); - - // Clean up old session - if (_session != null) - { - _session.KeepAlive -= OnSessionKeepAlive; - try { await _session.CloseAsync(); } catch { } - _session = null; - _subscription = null; - } - - await ConnectAsync(_reconnectCts.Token); - - // Re-add monitored items for active handles - lock (_lock) - { - foreach (var (handle, callback) in _handleCallbacks) - { - if (_handleItems.TryGetValue(handle, out var oldItems)) - { - var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList(); - var newItems = new List(); - - foreach (var nodeId in nodeIds) - { - var monitoredItem = new MonitoredItem(_subscription!.DefaultItem) - { - DisplayName = nodeId, - StartNodeId = nodeId, - AttributeId = Attributes.Value, - SamplingInterval = oldItems[0].SamplingInterval, - QueueSize = 10, - DiscardOldest = true - }; - - var capturedNodeId = nodeId; - var capturedCallback = callback; - monitoredItem.Notification += (item, ev) => - { - if (ev.NotificationValue is MonitoredItemNotification notification) - { - var val = notification.Value?.Value; - var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow; - var sc = notification.Value?.StatusCode.Code ?? 0; - capturedCallback(capturedNodeId, val, ts, sc); - } - }; - - newItems.Add(monitoredItem); - _subscription!.AddItem(monitoredItem); - } - - _handleItems[handle] = newItems; - } - } - } - - if (_subscription != null) - await _subscription.ApplyChangesAsync(); - - _reconnecting = false; - _logger.LogInformation("OPC UA reconnection successful"); - Reconnected?.Invoke(); - return; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s"); - } - } - }, _reconnectCts.Token); - } - - private void EnsureConnected() - { - if (!_connected || _session == null) - throw new InvalidOperationException("OPC UA backend unavailable"); - } - - public async ValueTask DisposeAsync() - { - _reconnectCts?.Cancel(); - _reconnectCts?.Dispose(); - - if (_subscription != null) - { - try { await _subscription.DeleteAsync(true); } catch { } - _subscription = null; - } - if (_session != null) - { - _session.KeepAlive -= OnSessionKeepAlive; - try { await _session.CloseAsync(); } catch { } - _session = null; - } - _connected = false; - } -} -``` - -**Step 3: Verify build** - -```bash -cd infra/lmxfakeproxy && dotnet build -``` - -Expected: 0 errors. - -**Step 4: Commit** - -```bash -git add infra/lmxfakeproxy/Bridge/ -git commit -m "feat(infra): add IOpcUaBridge interface and OpcUaBridge with OPC UA reconnection" -``` - ---- - -### Task 5: ScadaServiceImpl (gRPC Service) + Tests - -**Files:** -- Create: `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` -- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs` - -**Step 1: Write the failing tests** - -Create `tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`. These tests mock `IOpcUaBridge` and use real `SessionManager` + `TagMapper`: - -```csharp -using Grpc.Core; -using NSubstitute; -using LmxFakeProxy.Bridge; -using LmxFakeProxy.Grpc; -using LmxFakeProxy.Sessions; -using LmxFakeProxy.Services; - -namespace LmxFakeProxy.Tests; - -public class ScadaServiceTests -{ - private readonly IOpcUaBridge _mockBridge; - private readonly SessionManager _sessionMgr; - private readonly TagMapper _tagMapper; - private readonly ScadaServiceImpl _service; - - public ScadaServiceTests() - { - _mockBridge = Substitute.For(); - _mockBridge.IsConnected.Returns(true); - _sessionMgr = new SessionManager(null); - _tagMapper = new TagMapper("ns=3;s="); - _service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper); - } - - private string ConnectClient(string clientId = "test-client") - { - var (_, _, sessionId) = _sessionMgr.Connect(clientId, ""); - return sessionId; - } - - private static ServerCallContext MockContext() - { - return new TestServerCallContext(); - } - - // --- Connection --- - - [Fact] - public async Task Connect_ReturnsSessionId() - { - var resp = await _service.Connect( - new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext()); - Assert.True(resp.Success); - Assert.NotEmpty(resp.SessionId); - } - - // --- Read --- - - [Fact] - public async Task Read_ValidSession_ReturnsVtq() - { - var sid = ConnectClient(); - _mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any()) - .Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0)); - - var resp = await _service.Read( - new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext()); - - Assert.True(resp.Success); - Assert.Equal("42.5", resp.Vtq.Value); - Assert.Equal("Good", resp.Vtq.Quality); - } - - [Fact] - public async Task Read_InvalidSession_ReturnsFailure() - { - var resp = await _service.Read( - new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext()); - Assert.False(resp.Success); - Assert.Contains("Invalid", resp.Message); - } - - [Fact] - public async Task ReadBatch_ReturnsAllTags() - { - var sid = ConnectClient(); - _mockBridge.ReadAsync(Arg.Any(), Arg.Any()) - .Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0)); - - var req = new ReadBatchRequest { SessionId = sid }; - req.Tags.AddRange(["Motor.Speed", "Pump.FlowRate"]); - - var resp = await _service.ReadBatch(req, MockContext()); - - Assert.True(resp.Success); - Assert.Equal(2, resp.Vtqs.Count); - } - - // --- Write --- - - [Fact] - public async Task Write_ValidSession_Succeeds() - { - var sid = ConnectClient(); - _mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any(), Arg.Any()) - .Returns(0u); - - var resp = await _service.Write( - new WriteRequest { SessionId = sid, Tag = "Motor.Speed", Value = "42.5" }, MockContext()); - - Assert.True(resp.Success); - } - - [Fact] - public async Task Write_InvalidSession_ReturnsFailure() - { - var resp = await _service.Write( - new WriteRequest { SessionId = "bogus", Tag = "Motor.Speed", Value = "42.5" }, MockContext()); - Assert.False(resp.Success); - } - - [Fact] - public async Task WriteBatch_ReturnsPerItemResults() - { - var sid = ConnectClient(); - _mockBridge.WriteAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(0u); - - var req = new WriteBatchRequest { SessionId = sid }; - req.Items.Add(new WriteItem { Tag = "Motor.Speed", Value = "42.5" }); - req.Items.Add(new WriteItem { Tag = "Pump.FlowRate", Value = "10.0" }); - - var resp = await _service.WriteBatch(req, MockContext()); - - Assert.True(resp.Success); - Assert.Equal(2, resp.Results.Count); - Assert.All(resp.Results, r => Assert.True(r.Success)); - } - - // --- CheckApiKey --- - - [Fact] - public async Task CheckApiKey_Valid_ReturnsTrue() - { - var resp = await _service.CheckApiKey( - new CheckApiKeyRequest { ApiKey = "anything" }, MockContext()); - Assert.True(resp.IsValid); - } - - [Fact] - public async Task CheckApiKey_Invalid_ReturnsFalse() - { - var mgr = new SessionManager("secret"); - var svc = new ScadaServiceImpl(mgr, _mockBridge, _tagMapper); - - var resp = await svc.CheckApiKey( - new CheckApiKeyRequest { ApiKey = "wrong" }, MockContext()); - Assert.False(resp.IsValid); - } -} - -/// -/// Minimal ServerCallContext implementation for unit testing gRPC services. -/// -internal class TestServerCallContext : ServerCallContext -{ - protected override string MethodCore => "test"; - protected override string HostCore => "localhost"; - protected override string PeerCore => "test-peer"; - protected override DateTime DeadlineCore => DateTime.MaxValue; - protected override Metadata RequestHeadersCore => new(); - protected override CancellationToken CancellationTokenCore => CancellationToken.None; - protected override Metadata ResponseTrailersCore => new(); - protected override Status StatusCore { get; set; } - protected override WriteOptions? WriteOptionsCore { get; set; } - protected override AuthContext AuthContextCore => new("test", new Dictionary>()); - - protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => - throw new NotImplementedException(); - protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; -} -``` - -**Step 2: Run tests to verify they fail** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: FAIL — `ScadaServiceImpl` does not exist. - -**Step 3: Implement ScadaServiceImpl** - -Create `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs`: - -```csharp -using Grpc.Core; -using LmxFakeProxy.Bridge; -using LmxFakeProxy.Grpc; -using LmxFakeProxy.Sessions; - -namespace LmxFakeProxy.Services; - -public class ScadaServiceImpl : ScadaService.ScadaServiceBase -{ - private readonly SessionManager _sessions; - private readonly IOpcUaBridge _bridge; - private readonly TagMapper _tagMapper; - - public ScadaServiceImpl(SessionManager sessions, IOpcUaBridge bridge, TagMapper tagMapper) - { - _sessions = sessions; - _bridge = bridge; - _tagMapper = tagMapper; - } - - public override Task Connect(ConnectRequest request, ServerCallContext context) - { - var (success, message, sessionId) = _sessions.Connect(request.ClientId, request.ApiKey); - return Task.FromResult(new ConnectResponse - { - Success = success, - Message = message, - SessionId = sessionId - }); - } - - public override Task Disconnect(DisconnectRequest request, ServerCallContext context) - { - var ok = _sessions.Disconnect(request.SessionId); - return Task.FromResult(new DisconnectResponse - { - Success = ok, - Message = ok ? "Disconnected" : "Session not found" - }); - } - - public override Task GetConnectionState( - GetConnectionStateRequest request, ServerCallContext context) - { - var (found, clientId, ticks) = _sessions.GetConnectionState(request.SessionId); - return Task.FromResult(new GetConnectionStateResponse - { - IsConnected = found, - ClientId = clientId, - ConnectedSinceUtcTicks = ticks - }); - } - - public override Task CheckApiKey(CheckApiKeyRequest request, ServerCallContext context) - { - var valid = _sessions.CheckApiKey(request.ApiKey); - return Task.FromResult(new CheckApiKeyResponse - { - IsValid = valid, - Message = valid ? "Valid" : "Invalid API key" - }); - } - - public override async Task Read(ReadRequest request, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - return new ReadResponse { Success = false, Message = "Invalid or expired session" }; - - try - { - var nodeId = _tagMapper.ToOpcNodeId(request.Tag); - var result = await _bridge.ReadAsync(nodeId, context.CancellationToken); - return new ReadResponse - { - Success = true, - Vtq = TagMapper.ToVtqMessage(request.Tag, result.Value, result.SourceTimestamp, result.StatusCode) - }; - } - catch (Exception ex) - { - return new ReadResponse { Success = false, Message = ex.Message }; - } - } - - public override async Task ReadBatch(ReadBatchRequest request, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - return new ReadBatchResponse { Success = false, Message = "Invalid or expired session" }; - - var response = new ReadBatchResponse { Success = true }; - foreach (var tag in request.Tags) - { - try - { - var nodeId = _tagMapper.ToOpcNodeId(tag); - var result = await _bridge.ReadAsync(nodeId, context.CancellationToken); - response.Vtqs.Add(TagMapper.ToVtqMessage(tag, result.Value, result.SourceTimestamp, result.StatusCode)); - } - catch (Exception ex) - { - response.Vtqs.Add(new VtqMessage - { - Tag = tag, Value = "", Quality = "Bad", - TimestampUtcTicks = DateTime.UtcNow.Ticks - }); - response.Message = ex.Message; - } - } - return response; - } - - public override async Task Write(WriteRequest request, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - return new WriteResponse { Success = false, Message = "Invalid or expired session" }; - - try - { - var nodeId = _tagMapper.ToOpcNodeId(request.Tag); - var value = TagMapper.ParseWriteValue(request.Value); - var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); - - return statusCode == 0 - ? new WriteResponse { Success = true } - : new WriteResponse { Success = false, Message = $"OPC UA write failed: 0x{statusCode:X8}" }; - } - catch (Exception ex) - { - return new WriteResponse { Success = false, Message = ex.Message }; - } - } - - public override async Task WriteBatch(WriteBatchRequest request, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - return new WriteBatchResponse { Success = false, Message = "Invalid or expired session" }; - - var response = new WriteBatchResponse { Success = true }; - foreach (var item in request.Items) - { - try - { - var nodeId = _tagMapper.ToOpcNodeId(item.Tag); - var value = TagMapper.ParseWriteValue(item.Value); - var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); - - response.Results.Add(new Grpc.WriteResult - { - Tag = item.Tag, - Success = statusCode == 0, - Message = statusCode == 0 ? "" : $"0x{statusCode:X8}" - }); - - if (statusCode != 0) response.Success = false; - } - catch (Exception ex) - { - response.Results.Add(new Grpc.WriteResult - { - Tag = item.Tag, Success = false, Message = ex.Message - }); - response.Success = false; - } - } - return response; - } - - public override async Task WriteBatchAndWait( - WriteBatchAndWaitRequest request, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - return new WriteBatchAndWaitResponse { Success = false, Message = "Invalid or expired session" }; - - var startTime = DateTime.UtcNow; - - // Write all items - var writeResults = new List(); - var allWritesOk = true; - foreach (var item in request.Items) - { - try - { - var nodeId = _tagMapper.ToOpcNodeId(item.Tag); - var value = TagMapper.ParseWriteValue(item.Value); - var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken); - writeResults.Add(new Grpc.WriteResult - { - Tag = item.Tag, - Success = statusCode == 0, - Message = statusCode == 0 ? "" : $"0x{statusCode:X8}" - }); - if (statusCode != 0) allWritesOk = false; - } - catch (Exception ex) - { - writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message }); - allWritesOk = false; - } - } - - if (!allWritesOk) - { - var resp = new WriteBatchAndWaitResponse { Success = false, Message = "Write failed" }; - resp.WriteResults.AddRange(writeResults); - return resp; - } - - // Poll for flag value - var flagNodeId = _tagMapper.ToOpcNodeId(request.FlagTag); - var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000; - var pollMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; - var deadline = startTime.AddMilliseconds(timeoutMs); - - while (DateTime.UtcNow < deadline) - { - context.CancellationToken.ThrowIfCancellationRequested(); - try - { - var readResult = await _bridge.ReadAsync(flagNodeId, context.CancellationToken); - if (readResult.Value?.ToString() == request.FlagValue) - { - var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; - var resp = new WriteBatchAndWaitResponse - { - Success = true, FlagReached = true, ElapsedMs = elapsed - }; - resp.WriteResults.AddRange(writeResults); - return resp; - } - } - catch { /* read failure during poll — keep trying */ } - - await Task.Delay(pollMs, context.CancellationToken); - } - - var finalResp = new WriteBatchAndWaitResponse - { - Success = true, FlagReached = false, - ElapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds, - Message = "Timeout waiting for flag value" - }; - finalResp.WriteResults.AddRange(writeResults); - return finalResp; - } - - public override async Task Subscribe( - SubscribeRequest request, IServerStreamWriter responseStream, ServerCallContext context) - { - if (!_sessions.ValidateSession(request.SessionId)) - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session")); - - var nodeIds = request.Tags.Select(t => _tagMapper.ToOpcNodeId(t)).ToList(); - var tagByNodeId = request.Tags.Zip(nodeIds).ToDictionary(p => p.Second, p => p.First); - - var handle = await _bridge.AddMonitoredItemsAsync( - nodeIds, - request.SamplingMs, - (nodeId, value, timestamp, statusCode) => - { - if (tagByNodeId.TryGetValue(nodeId, out var tag)) - { - var vtq = TagMapper.ToVtqMessage(tag, value, timestamp, statusCode); - try { responseStream.WriteAsync(vtq).Wait(); } - catch { /* stream closed */ } - } - }, - context.CancellationToken); - - try - { - // Keep the stream open until the client cancels - await Task.Delay(Timeout.Infinite, context.CancellationToken); - } - catch (OperationCanceledException) { } - finally - { - await _bridge.RemoveMonitoredItemsAsync(handle); - } - } -} -``` - -**Step 4: Run tests to verify they pass** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n -``` - -Expected: All tests PASS (11 TagMapping + 12 SessionManager + 9 ScadaService = 32). - -**Step 5: Commit** - -```bash -git add infra/lmxfakeproxy/Services/ infra/lmxfakeproxy/tests/ -git commit -m "feat(infra): add ScadaServiceImpl with full proto parity for all RPCs" -``` - ---- - -### Task 6: Program.cs — Host Builder with CLI Args - -**Files:** -- Modify: `infra/lmxfakeproxy/Program.cs` - -**Step 1: Replace the minimal Program.cs with the full host builder** - -```csharp -using LmxFakeProxy; -using LmxFakeProxy.Bridge; -using LmxFakeProxy.Services; -using LmxFakeProxy.Sessions; - -var builder = WebApplication.CreateBuilder(args); - -// Configuration: env vars take precedence over CLI args -var port = Environment.GetEnvironmentVariable("PORT") ?? GetArg(args, "--port") ?? "50051"; -var opcEndpoint = Environment.GetEnvironmentVariable("OPC_ENDPOINT") ?? GetArg(args, "--opc-endpoint") ?? "opc.tcp://localhost:50000"; -var opcPrefix = Environment.GetEnvironmentVariable("OPC_PREFIX") ?? GetArg(args, "--opc-prefix") ?? "ns=3;s="; -var apiKey = Environment.GetEnvironmentVariable("API_KEY") ?? GetArg(args, "--api-key"); - -builder.WebHost.ConfigureKestrel(options => -{ - options.ListenAnyIP(int.Parse(port), listenOptions => - { - listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; - }); -}); - -// Register services -var sessionManager = new SessionManager(apiKey); -var tagMapper = new TagMapper(opcPrefix); -var opcUaBridge = new OpcUaBridge(opcEndpoint, builder.Services.BuildServiceProvider().GetRequiredService>()); - -builder.Services.AddSingleton(sessionManager); -builder.Services.AddSingleton(tagMapper); -builder.Services.AddSingleton(opcUaBridge); -builder.Services.AddGrpc(); - -var app = builder.Build(); - -app.MapGrpcService(); -app.MapGet("/", () => "LmxFakeProxy is running"); - -// Connect to OPC UA backend -var logger = app.Services.GetRequiredService>(); -logger.LogInformation("LmxFakeProxy starting on port {Port}", port); -logger.LogInformation("OPC UA endpoint: {Endpoint}, prefix: {Prefix}", opcEndpoint, opcPrefix); -logger.LogInformation("API key enforcement: {Enforced}", apiKey != null ? "enabled" : "disabled (accept all)"); - -try -{ - await opcUaBridge.ConnectAsync(); - logger.LogInformation("OPC UA bridge connected"); -} -catch (Exception ex) -{ - logger.LogWarning(ex, "Initial OPC UA connection failed — will retry in background"); -} - -await app.RunAsync(); - -static string? GetArg(string[] args, string name) -{ - var idx = Array.IndexOf(args, name); - return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null; -} -``` - -**Step 2: Verify build and basic startup** - -```bash -cd infra/lmxfakeproxy && dotnet build -``` - -Expected: 0 errors. - -**Step 3: Commit** - -```bash -git add infra/lmxfakeproxy/Program.cs -git commit -m "feat(infra): wire up Program.cs with CLI args, env vars, and OPC UA bridge startup" -``` - ---- - -### Task 7: Dockerfile + Docker Compose Integration - -**Files:** -- Create: `infra/lmxfakeproxy/Dockerfile` -- Modify: `infra/docker-compose.yml` - -**Step 1: Create the Dockerfile** - -Create `infra/lmxfakeproxy/Dockerfile`: - -```dockerfile -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY LmxFakeProxy.csproj . -RUN dotnet restore -COPY . . -RUN dotnet publish -c Release -o /app - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 -WORKDIR /app -COPY --from=build /app . -EXPOSE 50051 -ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"] -``` - -Note: Do NOT copy the `tests/` directory into the build — the `.dockerignore` or the `COPY` pattern handles this naturally since only the project root files and source folders are needed. - -**Step 2: Create `.dockerignore`** - -Create `infra/lmxfakeproxy/.dockerignore`: - -``` -tests/ -bin/ -obj/ -``` - -**Step 3: Add the service to docker-compose.yml** - -Add the following service block before the `volumes:` section in `infra/docker-compose.yml`: - -```yaml - lmxfakeproxy: - build: ./lmxfakeproxy - container_name: scadalink-lmxfakeproxy - ports: - - "50051:50051" - environment: - OPC_ENDPOINT: "opc.tcp://opcua:50000" - OPC_PREFIX: "ns=3;s=" - depends_on: - - opcua - networks: - - scadalink-net - restart: unless-stopped -``` - -**Step 4: Verify Docker build** - -```bash -cd infra && docker compose build lmxfakeproxy -``` - -Expected: Build succeeds. - -**Step 5: Commit** - -```bash -git add infra/lmxfakeproxy/Dockerfile infra/lmxfakeproxy/.dockerignore infra/docker-compose.yml -git commit -m "feat(infra): add LmxFakeProxy Dockerfile and docker-compose service" -``` - ---- - -### Task 8: Documentation Updates - -**Files:** -- Modify: `docs/test_infra/test_infra.md` -- Modify: `infra/README.md` -- Create: `docs/test_infra/test_infra_lmxfakeproxy.md` -- Modify: `docs/requirements/Component-DataConnectionLayer.md` - -**Step 1: Update docs/test_infra/test_infra.md** - -Add a row to the Services table: - -``` -| LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | -``` - -Add a bullet to the per-service documentation list: - -``` -- [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge) -``` - -Update the Files section to add: - -``` - lmxfakeproxy/ # .NET gRPC proxy bridging LmxProxy protocol to OPC UA -``` - -**Step 2: Update infra/README.md** - -Add a row to the quick-start table: - -``` -| LmxFakeProxy (.NET gRPC) | 50051 (gRPC) | LmxProxy-compatible server bridging to OPC UA test server | -``` - -**Step 3: Create docs/test_infra/test_infra_lmxfakeproxy.md** - -```markdown -# Test Infrastructure: LmxFakeProxy - -## Overview - -LmxFakeProxy is a .NET gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of `RealLmxProxyClient` and the LmxProxy DCL adapter. - -## Image & Ports - -- **Image**: Custom build (`infra/lmxfakeproxy/Dockerfile`) -- **gRPC endpoint**: `localhost:50051` - -## Configuration - -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `PORT` | `50051` | gRPC listen port | -| `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | Backend OPC UA server | -| `OPC_PREFIX` | `ns=3;s=` | Prefix prepended to LMX tags to form OPC UA NodeIds | -| `API_KEY` | *(none)* | If set, enforces API key on all gRPC calls | - -## Tag Address Mapping - -LMX-style flat addresses are mapped to OPC UA NodeIds by prepending the configured prefix: - -| LMX Tag | OPC UA NodeId | -|---------|--------------| -| `Motor.Speed` | `ns=3;s=Motor.Speed` | -| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` | -| `Tank.Level` | `ns=3;s=Tank.Level` | - -## Supported RPCs - -Full parity with the `scada.ScadaService` proto: - -- **Connect / Disconnect / GetConnectionState** — Session management -- **Read / ReadBatch** — Read tag values via OPC UA -- **Write / WriteBatch / WriteBatchAndWait** — Write values via OPC UA -- **Subscribe** — Server-streaming subscriptions via OPC UA MonitoredItems -- **CheckApiKey** — API key validation - -## Verification - -1. Ensure the OPC UA test server is running: -```bash -docker ps --filter name=scadalink-opcua -``` - -2. Start the fake proxy: -```bash -docker compose up -d lmxfakeproxy -``` - -3. Check logs: -```bash -docker logs scadalink-lmxfakeproxy -``` - -4. Test with the ScadaLink CLI or a gRPC client. - -## Running Standalone (without Docker) - -```bash -cd infra/lmxfakeproxy -dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s=" -``` - -With API key enforcement: -```bash -dotnet run -- --api-key my-secret-key -``` - -## Relevance to ScadaLink Components - -- **Data Connection Layer** — Test `RealLmxProxyClient` and `LmxProxyDataConnection` against real OPC UA data -- **Site Runtime** — Deploy instances with LmxProxy data connections pointing at this server -- **Integration Tests** — End-to-end tests of the LmxProxy protocol path -``` - -**Step 4: Update docs/requirements/Component-DataConnectionLayer.md** - -Add a note in the LmxProxy section (after the "Proto Source" paragraph, before "## Subscription Management"): - -```markdown -**Test Infrastructure**: The `infra/lmxfakeproxy/` project provides a fake LmxProxy server that bridges to the OPC UA test server. It implements the full `scada.ScadaService` proto, enabling end-to-end testing of `RealLmxProxyClient` without a Windows LmxProxy deployment. See [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) for setup. -``` - -**Step 5: Commit** - -```bash -git add docs/test_infra/test_infra.md docs/test_infra/test_infra_lmxfakeproxy.md infra/README.md docs/requirements/Component-DataConnectionLayer.md -git commit -m "docs: add LmxFakeProxy to test infrastructure documentation" -``` - ---- - -### Task 9: Integration Smoke Test with RealLmxProxyClient - -**Files:** -- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs` - -This test is an end-to-end smoke test that verifies the fake proxy works with the actual `RealLmxProxyClient`. It requires the OPC UA test server and LmxFakeProxy to both be running (Docker or standalone). Mark it with a `[Trait]` so it can be skipped in CI. - -**Step 1: Add the DCL project reference to the test csproj** - -Add to `tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj`: - -```xml - -``` - -**Step 2: Write the integration test** - -Create `tests/LmxFakeProxy.Tests/IntegrationSmokeTest.cs`: - -```csharp -using ScadaLink.DataConnectionLayer.Adapters; - -namespace LmxFakeProxy.Tests; - -/// -/// End-to-end smoke test that connects RealLmxProxyClient to LmxFakeProxy. -/// Requires both OPC UA test server and LmxFakeProxy to be running. -/// Run manually: dotnet test --filter "Category=Integration" -/// -[Trait("Category", "Integration")] -public class IntegrationSmokeTest -{ - private const string Host = "localhost"; - private const int Port = 50051; - - [Fact] - public async Task ConnectReadWriteSubscribe_EndToEnd() - { - var client = new RealLmxProxyClient(Host, Port, apiKey: null); - - try - { - // Connect - await client.ConnectAsync(); - Assert.True(client.IsConnected); - - // Read initial value - var vtq = await client.ReadAsync("Motor.Speed"); - Assert.Equal(LmxQuality.Good, vtq.Quality); - - // Write a value - await client.WriteAsync("Motor.Speed", 42.5); - - // Read back - var vtq2 = await client.ReadAsync("Motor.Speed"); - Assert.Equal(42.5, (double)vtq2.Value!); - - // ReadBatch - var batch = await client.ReadBatchAsync(["Motor.Speed", "Pump.FlowRate"]); - Assert.Equal(2, batch.Count); - - // Subscribe briefly - LmxVtq? lastUpdate = null; - var sub = await client.SubscribeAsync( - ["Motor.Speed"], - (tag, v) => lastUpdate = v); - - // Write to trigger subscription update - await client.WriteAsync("Motor.Speed", 99.0); - await Task.Delay(2000); // Wait for subscription delivery - - await sub.DisposeAsync(); - - // Verify we got at least one subscription update - Assert.NotNull(lastUpdate); - - // Disconnect - await client.DisconnectAsync(); - } - finally - { - await client.DisposeAsync(); - } - } -} -``` - -**Step 3: Verify build (do NOT run yet — requires running infra)** - -```bash -cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/ -``` - -Expected: 0 errors. - -**Step 4: Run unit tests only (exclude integration)** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n -``` - -Expected: All unit tests pass. Integration test is skipped. - -**Step 5: Commit** - -```bash -git add infra/lmxfakeproxy/tests/ -git commit -m "test(infra): add integration smoke test for RealLmxProxyClient against LmxFakeProxy" -``` - ---- - -### Task 10: End-to-End Verification - -**No new files — verification only.** - -**Step 1: Start the infrastructure** - -```bash -cd infra && docker compose up -d -``` - -Wait for OPC UA server to be ready: - -```bash -docker logs scadalink-opcua 2>&1 | tail -5 -``` - -**Step 2: Verify LmxFakeProxy logs** - -```bash -docker logs scadalink-lmxfakeproxy -``` - -Expected: "OPC UA bridge connected" message. - -**Step 3: Run the integration smoke test** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category=Integration" -v n -``` - -Expected: Integration test passes — connect, read, write, read-back, subscribe all work. - -**Step 4: Run all unit tests to confirm no regressions** - -```bash -cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ --filter "Category!=Integration" -v n -``` - -Expected: All unit tests pass. - -**Step 5: Final commit (if any fixes were needed)** - -```bash -git add -A && git commit -m "fix(infra): address issues found during end-to-end verification" -``` - -Only commit if changes were needed. If everything passed cleanly, skip this step. diff --git a/deprecated/2026-03-19-lmxfakeproxy-implementation.md.tasks.json b/deprecated/2026-03-19-lmxfakeproxy-implementation.md.tasks.json deleted file mode 100644 index 46a097a..0000000 --- a/deprecated/2026-03-19-lmxfakeproxy-implementation.md.tasks.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "planPath": "docs/plans/2026-03-19-lmxfakeproxy-implementation.md", - "tasks": [ - {"id": 1, "nativeId": "3", "subject": "Task 1: Project Scaffolding", "status": "pending"}, - {"id": 2, "nativeId": "4", "subject": "Task 2: TagMapper Utility + Tests", "status": "pending", "blockedBy": [1]}, - {"id": 3, "nativeId": "5", "subject": "Task 3: SessionManager + Tests", "status": "pending", "blockedBy": [1]}, - {"id": 4, "nativeId": "6", "subject": "Task 4: IOpcUaBridge + OpcUaBridge Implementation", "status": "pending", "blockedBy": [1]}, - {"id": 5, "nativeId": "7", "subject": "Task 5: ScadaServiceImpl + Tests", "status": "pending", "blockedBy": [2, 3, 4]}, - {"id": 6, "nativeId": "8", "subject": "Task 6: Program.cs Host Builder", "status": "pending", "blockedBy": [5]}, - {"id": 7, "nativeId": "9", "subject": "Task 7: Dockerfile + Docker Compose", "status": "pending", "blockedBy": [6]}, - {"id": 8, "nativeId": "10", "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [6]}, - {"id": 9, "nativeId": "11", "subject": "Task 9: Integration Smoke Test", "status": "pending", "blockedBy": [5, 7]}, - {"id": 10, "nativeId": "12", "subject": "Task 10: End-to-End Verification", "status": "pending", "blockedBy": [7, 8, 9]} - ], - "lastUpdated": "2026-03-19T00:00:00Z" -} diff --git a/deprecated/ILmxProxyClient.cs b/deprecated/ILmxProxyClient.cs deleted file mode 100644 index 3671adc..0000000 --- a/deprecated/ILmxProxyClient.cs +++ /dev/null @@ -1,43 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ScadaLink.DataConnectionLayer.Adapters; - -/// -/// Subscription handle returned by . -/// Disposing the subscription stops receiving updates. -/// -public interface ILmxSubscription : IAsyncDisposable { } - -/// -/// Abstraction over the LmxProxy SDK client for testability. -/// The production implementation delegates to the real -/// library. -/// -public interface ILmxProxyClient : IAsyncDisposable -{ - bool IsConnected { get; } - - Task ConnectAsync(CancellationToken cancellationToken = default); - Task DisconnectAsync(); - - Task ReadAsync(string address, CancellationToken cancellationToken = default); - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); - - Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default); - Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); - - Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default); -} - -/// -/// Factory for creating instances configured -/// with host, port, and optional API key. -/// -public interface ILmxProxyClientFactory -{ - ILmxProxyClient Create(string host, int port, string? apiKey, bool useTls = false); -} diff --git a/deprecated/LmxProxyDataConnection.cs b/deprecated/LmxProxyDataConnection.cs deleted file mode 100644 index 6da1ef0..0000000 --- a/deprecated/LmxProxyDataConnection.cs +++ /dev/null @@ -1,314 +0,0 @@ -using Microsoft.Extensions.Logging; -using ScadaLink.Commons.Interfaces.Protocol; -using ScadaLink.Commons.Types.Enums; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ScadaLink.Commons.Types; -using QualityCode = ScadaLink.Commons.Interfaces.Protocol.QualityCode; -using WriteResult = ScadaLink.Commons.Interfaces.Protocol.WriteResult; - -namespace ScadaLink.DataConnectionLayer.Adapters; - -/// -/// LmxProxy adapter implementing IDataConnection. -/// Maps IDataConnection operations to the real LmxProxy SDK client -/// via the abstraction. -/// -/// LmxProxy-specific behavior: -/// - Session-based connection with automatic 30s keep-alive (managed by SDK) -/// - gRPC streaming for subscriptions via ILmxSubscription handles -/// - API key authentication via x-api-key gRPC metadata header -/// - Native TypedValue writes (v2 protocol) -/// -public class LmxProxyDataConnection : IDataConnection -{ - private readonly ILmxProxyClientFactory _clientFactory; - private readonly ILogger _logger; - private ILmxProxyClient? _client; - private string _host = "localhost"; - private int _port = 50051; - private ConnectionHealth _status = ConnectionHealth.Disconnected; - - private readonly Dictionary _subscriptions = new(); - private volatile bool _disconnectFired; - private StaleTagMonitor? _staleMonitor; - private string? _heartbeatSubscriptionId; - - public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger logger) - { - _clientFactory = clientFactory; - _logger = logger; - } - - public ConnectionHealth Status => _status; - public event Action? Disconnected; - - public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) - { - _host = connectionDetails.TryGetValue("Host", out var host) ? host : "localhost"; - if (connectionDetails.TryGetValue("Port", out var portStr) && int.TryParse(portStr, out var port)) - _port = port; - connectionDetails.TryGetValue("ApiKey", out var apiKey); - - var useTls = connectionDetails.TryGetValue("UseTls", out var tlsStr) && bool.TryParse(tlsStr, out var tls) && tls; - - _status = ConnectionHealth.Connecting; - _client = _clientFactory.Create(_host, _port, apiKey, useTls); - - await _client.ConnectAsync(cancellationToken); - _status = ConnectionHealth.Connected; - _disconnectFired = false; - - _logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port); - - // Heartbeat stale tag monitoring (optional) - await StartHeartbeatMonitorAsync(connectionDetails, cancellationToken); - } - - private async Task StartHeartbeatMonitorAsync(IDictionary connectionDetails, CancellationToken cancellationToken) - { - if (!connectionDetails.TryGetValue("HeartbeatTagPath", out var heartbeatTag) || string.IsNullOrWhiteSpace(heartbeatTag)) - return; - - var maxSilenceSeconds = connectionDetails.TryGetValue("HeartbeatMaxSilence", out var silenceStr) - && int.TryParse(silenceStr, out var sec) ? sec : 30; - - _staleMonitor?.Dispose(); - _staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(maxSilenceSeconds)); - _staleMonitor.Stale += () => - { - _logger.LogWarning("LmxProxy heartbeat tag '{Tag}' stale — no update in {Seconds}s", heartbeatTag, maxSilenceSeconds); - RaiseDisconnected(); - }; - - try - { - _heartbeatSubscriptionId = await SubscribeAsync(heartbeatTag, (tag, value) => - { - _logger.LogDebug("LmxProxy heartbeat received: {Tag} = {Value} (quality={Quality})", tag, value.Value, value.Quality); - _staleMonitor.OnValueReceived(); - }, cancellationToken); - _staleMonitor.Start(); - _logger.LogInformation("LmxProxy heartbeat monitor started for '{Tag}' with {Seconds}s max silence", heartbeatTag, maxSilenceSeconds); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active", heartbeatTag); - _staleMonitor.Dispose(); - _staleMonitor = null; - } - } - - public async Task DisconnectAsync(CancellationToken cancellationToken = default) - { - StopHeartbeatMonitor(); - if (_client != null) - { - await _client.DisconnectAsync(); - _status = ConnectionHealth.Disconnected; - _logger.LogInformation("LmxProxy disconnected from {Host}:{Port}", _host, _port); - } - } - - public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - try - { - var vtq = await _client!.ReadAsync(tagPath, cancellationToken); - var quality = MapQuality(vtq.Quality); - var tagValue = new TagValue(NormalizeValue(vtq.Value), quality, new DateTimeOffset(vtq.Timestamp, TimeSpan.Zero)); - - return vtq.Quality.IsBad() - ? new ReadResult(false, tagValue, "LmxProxy read returned bad quality") - : new ReadResult(true, tagValue, null); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "LmxProxy read failed for {TagPath} — connection may be lost", tagPath); - RaiseDisconnected(); - throw; - } - } - - public async Task> ReadBatchAsync(IEnumerable tagPaths, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var vtqs = await _client!.ReadBatchAsync(tagPaths, cancellationToken); - var results = new Dictionary(); - - foreach (var (tag, vtq) in vtqs) - { - var quality = MapQuality(vtq.Quality); - var tagValue = new TagValue(NormalizeValue(vtq.Value), quality, new DateTimeOffset(vtq.Timestamp, TimeSpan.Zero)); - results[tag] = vtq.Quality.IsBad() - ? new ReadResult(false, tagValue, "LmxProxy read returned bad quality") - : new ReadResult(true, tagValue, null); - } - - return results; - } - - public async Task WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - try - { - await _client!.WriteAsync(tagPath, ToTypedValue(value), cancellationToken); - return new WriteResult(true, null); - } - catch (Exception ex) - { - return new WriteResult(false, ex.Message); - } - } - - public async Task> WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - try - { - var typedValues = values.ToDictionary(kv => kv.Key, kv => ToTypedValue(kv.Value)); - await _client!.WriteBatchAsync(typedValues, cancellationToken); - - return values.Keys.ToDictionary(k => k, _ => new WriteResult(true, null)) - as IReadOnlyDictionary; - } - catch (Exception ex) - { - return values.Keys.ToDictionary(k => k, _ => new WriteResult(false, ex.Message)) - as IReadOnlyDictionary; - } - } - - public async Task WriteBatchAndWaitAsync( - IDictionary values, string flagPath, object? flagValue, - string responsePath, object? responseValue, TimeSpan timeout, - CancellationToken cancellationToken = default) - { - var allValues = new Dictionary(values) { [flagPath] = flagValue }; - var writeResults = await WriteBatchAsync(allValues, cancellationToken); - - if (writeResults.Values.Any(r => !r.Success)) - return false; - - var deadline = DateTimeOffset.UtcNow + timeout; - while (DateTimeOffset.UtcNow < deadline) - { - cancellationToken.ThrowIfCancellationRequested(); - var readResult = await ReadAsync(responsePath, cancellationToken); - if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue)) - return true; - - await Task.Delay(100, cancellationToken); - } - - return false; - } - - public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var subscription = await _client!.SubscribeAsync( - [tagPath], - (path, vtq) => - { - var quality = MapQuality(vtq.Quality); - callback(path, new TagValue(NormalizeValue(vtq.Value), quality, new DateTimeOffset(vtq.Timestamp, TimeSpan.Zero))); - }, - onStreamError: ex => - { - _logger.LogWarning(ex, "LmxProxy subscription stream ended unexpectedly for {TagPath}", tagPath); - RaiseDisconnected(); - }, - cancellationToken); - - var subscriptionId = Guid.NewGuid().ToString("N"); - _subscriptions[subscriptionId] = subscription; - return subscriptionId; - } - - public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) - { - if (_subscriptions.Remove(subscriptionId, out var subscription)) - { - await subscription.DisposeAsync(); - } - } - - private void StopHeartbeatMonitor() - { - _staleMonitor?.Dispose(); - _staleMonitor = null; - _heartbeatSubscriptionId = null; - } - - public async ValueTask DisposeAsync() - { - StopHeartbeatMonitor(); - foreach (var subscription in _subscriptions.Values) - { - try { await subscription.DisposeAsync(); } - catch { /* best-effort cleanup */ } - } - _subscriptions.Clear(); - - if (_client != null) - { - await _client.DisposeAsync(); - _client = null; - } - _status = ConnectionHealth.Disconnected; - } - - private void EnsureConnected() - { - if (_client == null || !_client.IsConnected) - throw new InvalidOperationException("LmxProxy client is not connected."); - } - - private void RaiseDisconnected() - { - if (_disconnectFired) return; - _disconnectFired = true; - _status = ConnectionHealth.Disconnected; - _logger.LogWarning("LmxProxy connection to {Host}:{Port} lost", _host, _port); - Disconnected?.Invoke(); - } - - /// - /// Normalizes a Vtq value for consumption by the rest of the system. - /// Converts .NET arrays (bool[], int[], DateTime[], etc.) to comma-separated - /// display strings so downstream code sees simple string representations. - /// - private static object? NormalizeValue(object? value) => value switch - { - null or string => value, - IFormattable => value, - _ => ValueFormatter.FormatDisplayValue(value) - }; - - private static QualityCode MapQuality(Quality quality) - { - if (quality.IsGood()) return QualityCode.Good; - if (quality.IsUncertain()) return QualityCode.Uncertain; - return QualityCode.Bad; - } - - private static TypedValue ToTypedValue(object? value) => value switch - { - bool b => new TypedValue { BoolValue = b }, - int i => new TypedValue { Int32Value = i }, - long l => new TypedValue { Int64Value = l }, - float f => new TypedValue { FloatValue = f }, - double d => new TypedValue { DoubleValue = d }, - string s => new TypedValue { StringValue = s }, - DateTime dt => new TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks }, - null => new TypedValue { StringValue = string.Empty }, - _ => new TypedValue { StringValue = value.ToString() ?? string.Empty } - }; -} diff --git a/deprecated/LmxProxyDataConnectionTests.cs b/deprecated/LmxProxyDataConnectionTests.cs deleted file mode 100644 index f4c0013..0000000 --- a/deprecated/LmxProxyDataConnectionTests.cs +++ /dev/null @@ -1,309 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using ScadaLink.Commons.Interfaces.Protocol; -using ScadaLink.Commons.Types.Enums; -using ScadaLink.DataConnectionLayer.Adapters; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using QualityCode = ScadaLink.Commons.Interfaces.Protocol.QualityCode; - -namespace ScadaLink.DataConnectionLayer.Tests; - -public class LmxProxyDataConnectionTests -{ - private readonly ILmxProxyClient _mockClient; - private readonly ILmxProxyClientFactory _mockFactory; - private readonly LmxProxyDataConnection _adapter; - - public LmxProxyDataConnectionTests() - { - _mockClient = Substitute.For(); - _mockFactory = Substitute.For(); - _mockFactory.Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(_mockClient); - _adapter = new LmxProxyDataConnection(_mockFactory, NullLogger.Instance); - } - - private async Task ConnectAdapter(Dictionary? details = null) - { - _mockClient.IsConnected.Returns(true); - await _adapter.ConnectAsync(details ?? new Dictionary()); - } - - // --- Connection --- - - [Fact] - public async Task Connect_SetsStatusToConnected() - { - _mockClient.IsConnected.Returns(true); - - await _adapter.ConnectAsync(new Dictionary - { - ["Host"] = "myhost", - ["Port"] = "5001" - }); - - Assert.Equal(ConnectionHealth.Connected, _adapter.Status); - _mockFactory.Received(1).Create("myhost", 5001, null, false); - await _mockClient.Received(1).ConnectAsync(Arg.Any()); - } - - [Fact] - public async Task Connect_ExtractsApiKeyFromDetails() - { - _mockClient.IsConnected.Returns(true); - - await _adapter.ConnectAsync(new Dictionary - { - ["Host"] = "server", - ["Port"] = "50051", - ["ApiKey"] = "my-secret-key" - }); - - _mockFactory.Received(1).Create("server", 50051, "my-secret-key", false); - } - - [Fact] - public async Task Connect_DefaultsHostAndPort() - { - _mockClient.IsConnected.Returns(true); - - await _adapter.ConnectAsync(new Dictionary()); - - _mockFactory.Received(1).Create("localhost", 50051, null, false); - } - - [Fact] - public async Task Disconnect_SetsStatusToDisconnected() - { - await ConnectAdapter(); - await _adapter.DisconnectAsync(); - - Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); - await _mockClient.Received(1).DisconnectAsync(); - } - - // --- Read --- - - [Fact] - public async Task Read_Good_ReturnsSuccessWithValue() - { - await ConnectAdapter(); - var now = DateTime.UtcNow; - _mockClient.ReadAsync("Tag1", Arg.Any()) - .Returns(new Vtq(42.5, now, Quality.Good)); - - var result = await _adapter.ReadAsync("Tag1"); - - Assert.True(result.Success); - Assert.Equal(42.5, result.Value!.Value); - Assert.Equal(QualityCode.Good, result.Value.Quality); - } - - [Fact] - public async Task Read_Bad_ReturnsFailureWithValue() - { - await ConnectAdapter(); - _mockClient.ReadAsync("Tag1", Arg.Any()) - .Returns(new Vtq(null, DateTime.UtcNow, Quality.Bad)); - - var result = await _adapter.ReadAsync("Tag1"); - - Assert.False(result.Success); - Assert.NotNull(result.Value); - Assert.Equal(QualityCode.Bad, result.Value!.Quality); - } - - [Fact] - public async Task Read_Uncertain_MapsQuality() - { - await ConnectAdapter(); - _mockClient.ReadAsync("Tag1", Arg.Any()) - .Returns(new Vtq("maybe", DateTime.UtcNow, Quality.Uncertain)); - - var result = await _adapter.ReadAsync("Tag1"); - - Assert.True(result.Success); - Assert.Equal(QualityCode.Uncertain, result.Value!.Quality); - } - - [Fact] - public async Task ReadBatch_ReturnsMappedResults() - { - await ConnectAdapter(); - var now = DateTime.UtcNow; - _mockClient.ReadBatchAsync(Arg.Any>(), Arg.Any()) - .Returns(new Dictionary - { - ["Tag1"] = new(10, now, Quality.Good), - ["Tag2"] = new(null, now, Quality.Bad) - }); - - var results = await _adapter.ReadBatchAsync(["Tag1", "Tag2"]); - - Assert.True(results["Tag1"].Success); - Assert.Equal(10, results["Tag1"].Value!.Value); - Assert.False(results["Tag2"].Success); - } - - // --- Write --- - - [Fact] - public async Task Write_Success_ReturnsGoodResult() - { - await ConnectAdapter(); - - var result = await _adapter.WriteAsync("Tag1", 42); - - Assert.True(result.Success); - await _mockClient.Received(1).WriteAsync("Tag1", Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Write_Failure_ReturnsError() - { - await ConnectAdapter(); - _mockClient.WriteAsync("Tag1", Arg.Any(), Arg.Any()) - .Throws(new InvalidOperationException("Write failed for tag")); - - var result = await _adapter.WriteAsync("Tag1", 42); - - Assert.False(result.Success); - Assert.Contains("Write failed for tag", result.ErrorMessage); - } - - [Fact] - public async Task WriteBatch_Success_ReturnsAllGood() - { - await ConnectAdapter(); - - var results = await _adapter.WriteBatchAsync(new Dictionary { ["T1"] = 1, ["T2"] = 2 }); - - Assert.True(results["T1"].Success); - Assert.True(results["T2"].Success); - } - - [Fact] - public async Task WriteBatch_Failure_ReturnsAllErrors() - { - await ConnectAdapter(); - _mockClient.WriteBatchAsync(Arg.Any>(), Arg.Any()) - .Throws(new InvalidOperationException("Batch write failed")); - - var results = await _adapter.WriteBatchAsync(new Dictionary { ["T1"] = 1, ["T2"] = 2 }); - - Assert.False(results["T1"].Success); - Assert.False(results["T2"].Success); - Assert.Contains("Batch write failed", results["T1"].ErrorMessage); - } - - // --- Subscribe --- - - [Fact] - public async Task Subscribe_CreatesSubscriptionAndReturnsId() - { - await ConnectAdapter(); - var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any?>(), Arg.Any()) - .Returns(mockSub); - - var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { }); - - Assert.NotNull(subId); - Assert.NotEmpty(subId); - await _mockClient.Received(1).SubscribeAsync( - Arg.Any>(), Arg.Any>(), Arg.Any?>(), Arg.Any()); - } - - [Fact] - public async Task Unsubscribe_DisposesSubscription() - { - await ConnectAdapter(); - var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any?>(), Arg.Any()) - .Returns(mockSub); - - var subId = await _adapter.SubscribeAsync("Tag1", (_, _) => { }); - await _adapter.UnsubscribeAsync(subId); - - await mockSub.Received(1).DisposeAsync(); - } - - [Fact] - public async Task Unsubscribe_UnknownId_DoesNotThrow() - { - await ConnectAdapter(); - await _adapter.UnsubscribeAsync("nonexistent-id"); - } - - // --- Dispose --- - - [Fact] - public async Task Dispose_DisposesClientAndSubscriptions() - { - await ConnectAdapter(); - var mockSub = Substitute.For(); - _mockClient.SubscribeAsync(Arg.Any>(), Arg.Any>(), Arg.Any?>(), Arg.Any()) - .Returns(mockSub); - await _adapter.SubscribeAsync("Tag1", (_, _) => { }); - - await _adapter.DisposeAsync(); - - await mockSub.Received(1).DisposeAsync(); - await _mockClient.Received(1).DisposeAsync(); - Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status); - } - - // --- Guard --- - - [Fact] - public async Task NotConnected_ThrowsOnRead() - { - _mockClient.IsConnected.Returns(false); - - await Assert.ThrowsAsync(() => _adapter.ReadAsync("tag1")); - } - - [Fact] - public async Task NotConnected_ThrowsOnWrite() - { - _mockClient.IsConnected.Returns(false); - - await Assert.ThrowsAsync(() => _adapter.WriteAsync("tag1", 1)); - } - - [Fact] - public async Task NotConnected_ThrowsOnSubscribe() - { - _mockClient.IsConnected.Returns(false); - - await Assert.ThrowsAsync(() => - _adapter.SubscribeAsync("tag1", (_, _) => { })); - } - - // --- Configuration Parsing --- - - [Fact] - public async Task Connect_ParsesUseTls() - { - _mockClient.IsConnected.Returns(true); - - await _adapter.ConnectAsync(new Dictionary - { - ["Host"] = "server", - ["Port"] = "50051", - ["UseTls"] = "true" - }); - - _mockFactory.Received(1).Create("server", 50051, null, true); - } - - [Fact] - public async Task Connect_DefaultsHostPortAndTls() - { - _mockClient.IsConnected.Returns(true); - - await _adapter.ConnectAsync(new Dictionary()); - - _mockFactory.Received(1).Create("localhost", 50051, null, false); - } -} diff --git a/deprecated/RealLmxProxyClient.cs b/deprecated/RealLmxProxyClient.cs deleted file mode 100644 index 1c0e5f3..0000000 --- a/deprecated/RealLmxProxyClient.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.LmxProxy.Client; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ScadaLink.DataConnectionLayer.Adapters; - -/// -/// Production ILmxProxyClient that delegates to the real -/// library. -/// -internal class RealLmxProxyClient : ILmxProxyClient -{ - private readonly ZB.MOM.WW.LmxProxy.Client.LmxProxyClient _inner; - - public RealLmxProxyClient(ZB.MOM.WW.LmxProxy.Client.LmxProxyClient inner) - { - _inner = inner; - } - - public bool IsConnected => _inner.IsConnected; - - public Task ConnectAsync(CancellationToken cancellationToken = default) - => _inner.ConnectAsync(cancellationToken); - - public Task DisconnectAsync() - => _inner.DisconnectAsync(); - - public Task ReadAsync(string address, CancellationToken cancellationToken = default) - => _inner.ReadAsync(address, cancellationToken); - - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default) - => _inner.ReadBatchAsync(addresses, cancellationToken); - - public Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) - => _inner.WriteAsync(address, value, cancellationToken); - - public Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) - => _inner.WriteBatchAsync(values, cancellationToken); - - public async Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default) - { - var innerSub = await _inner.SubscribeAsync(addresses, onUpdate, onStreamError, cancellationToken); - return new SubscriptionWrapper(innerSub); - } - - public async ValueTask DisposeAsync() - { - await _inner.DisposeAsync(); - } - - private sealed class SubscriptionWrapper(ZB.MOM.WW.LmxProxy.Client.LmxProxyClient.ISubscription inner) : ILmxSubscription - { - public async ValueTask DisposeAsync() - { - await inner.DisposeAsync(); - } - } -} - -/// -/// Production factory that creates LmxProxy clients using the real library's builder. -/// -public class RealLmxProxyClientFactory : ILmxProxyClientFactory -{ - private readonly ILoggerFactory _loggerFactory; - - public RealLmxProxyClientFactory(ILoggerFactory loggerFactory) - { - _loggerFactory = loggerFactory; - } - - public ILmxProxyClient Create(string host, int port, string? apiKey, bool useTls = false) - { - var builder = new LmxProxyClientBuilder() - .WithHost(host) - .WithPort(port) - .WithLogger(_loggerFactory.CreateLogger()); - - if (!string.IsNullOrEmpty(apiKey)) - builder.WithApiKey(apiKey); - - if (useTls) - builder.WithSslCredentials(null); - - var client = builder.Build(); - return new RealLmxProxyClient(client); - } -} diff --git a/deprecated/docs-lmxproxy_protocol.md b/deprecated/docs-lmxproxy_protocol.md deleted file mode 100644 index b0743d9..0000000 --- a/deprecated/docs-lmxproxy_protocol.md +++ /dev/null @@ -1,388 +0,0 @@ -# LmxProxy Protocol Specification - -> **Note:** This specification reflects the v2 protocol with native `TypedValue` support. The original v1 string-based protocol (string values, string quality) has been replaced. - -The LmxProxy protocol is a gRPC-based SCADA read/write interface for bridging ScadaLink's Data Connection Layer to devices via an intermediary proxy server (LmxProxy). The proxy translates LmxProxy protocol operations into backend device calls (e.g., OPC UA). All communication uses HTTP/2 gRPC with Protocol Buffers. - -## Service Definition - -```protobuf -syntax = "proto3"; -package scada; - -service ScadaService { - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} -``` - -Proto file location: `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` - -## Connection Lifecycle - -### Session Model - -Every client must call `Connect` before performing any read, write, or subscribe operation. The server returns a session ID (32-character hex GUID) that must be included in all subsequent requests. Sessions persist until `Disconnect` is called or the server restarts — there is no idle timeout. - -### Authentication - -API key authentication is optional, controlled by server configuration: - -- **If required**: The `Connect` RPC fails with `success=false` if the API key doesn't match. -- **If not required**: All API keys are accepted (including empty). -- The API key is sent both in the `ConnectRequest.api_key` field and as an `x-api-key` gRPC metadata header on the `Connect` call. - -### Connect - -``` -ConnectRequest { - client_id: string // Client identifier (e.g., "ScadaLink-{guid}") - api_key: string // API key for authentication (empty if none) -} - -ConnectResponse { - success: bool // Whether connection succeeded - message: string // Status message - session_id: string // 32-char hex GUID (only valid if success=true) -} -``` - -The client generates `client_id` as `"ScadaLink-{Guid:N}"` for uniqueness. - -### Disconnect - -``` -DisconnectRequest { - session_id: string -} - -DisconnectResponse { - success: bool - message: string -} -``` - -Best-effort — the client calls disconnect but does not retry on failure. - -### GetConnectionState - -``` -GetConnectionStateRequest { - session_id: string -} - -GetConnectionStateResponse { - is_connected: bool - client_id: string - connected_since_utc_ticks: int64 // DateTime.UtcNow.Ticks at connect time -} -``` - -### CheckApiKey - -``` -CheckApiKeyRequest { - api_key: string -} - -CheckApiKeyResponse { - is_valid: bool - message: string -} -``` - -Standalone API key validation without creating a session. - -## Value-Timestamp-Quality (VTQ) - -The core data structure for all read and subscription results: - -``` -VtqMessage { - tag: string // Tag address - value: TypedValue // Native typed value (protobuf oneof) - timestamp_utc_ticks: int64 // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - quality: QualityCode // OPC UA status code + symbolic name -} -``` - -### TypedValue - -Values are transmitted as native types via a protobuf `oneof`: - -| Oneof Variant | Proto Type | .NET Type | -|---|---|---| -| `bool_value` | `bool` | `bool` | -| `int32_value` | `int32` | `int` | -| `int64_value` | `int64` | `long` | -| `float_value` | `float` | `float` | -| `double_value` | `double` | `double` | -| `string_value` | `string` | `string` | -| `bytes_value` | `bytes` | `byte[]` | -| `datetime_value` | `int64` | `DateTime` (UTC ticks) | -| `array_value` | `ArrayValue` | See below | - -### ArrayValue - -`ArrayValue` contains typed sub-arrays via a protobuf `oneof`: - -| Sub-array | Element Type | -|---|---| -| `BoolArray` | `repeated bool` | -| `Int32Array` | `repeated int32` | -| `Int64Array` | `repeated int64` | -| `FloatArray` | `repeated float` | -| `DoubleArray` | `repeated double` | -| `StringArray` | `repeated string` | - -> **Note:** `DateTime` arrays are not natively supported in the proto — they are serialized as `Int64Array` (UTC ticks) by the Host. - -The ScadaLink adapter normalizes `ArrayValue` objects to comma-separated display strings at the adapter boundary (see [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md#value-serialization)). - -### Value Encoding (v1 — deprecated) - -The v1 protocol transmitted all values as strings with client-side parsing (`double.TryParse`, `bool.TryParse`). This has been replaced by native `TypedValue`. The v1 heuristics are no longer used. - -### Quality Codes - -Quality is transmitted as a `QualityCode` enum with OPC UA status code semantics: - -| QualityCode | Meaning | OPC UA Mapping | -|---|---|---| -| Good | Value is reliable | StatusCode high bits clear | -| Uncertain | Value may not be current | Non-zero, high bit clear | -| Bad | Value is unreliable or unavailable | High bit set (`0x80000000`) | - -The SDK provides `IsGood()`, `IsUncertain()`, and `IsBad()` extension methods on the `Quality` enum. The adapter maps these to ScadaLink's `QualityCode`. - -A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp. - -### Timestamps - -- All timestamps are UTC. -- Encoded as `int64` representing `DateTime.Ticks` (100-nanosecond intervals since 0001-01-01 00:00:00 UTC). -- Client reconstructs via `new DateTime(ticks, DateTimeKind.Utc)`. - -## Read Operations - -### Read (Single Tag) - -``` -ReadRequest { - session_id: string // Valid session ID - tag: string // Tag address -} - -ReadResponse { - success: bool // Whether read succeeded - message: string // Error message if failed - vtq: VtqMessage // Value-timestamp-quality result -} -``` - -### ReadBatch (Multiple Tags) - -``` -ReadBatchRequest { - session_id: string - tags: repeated string // Tag addresses -} - -ReadBatchResponse { - success: bool // false if any tag failed - message: string // Error message - vtqs: repeated VtqMessage // Results in same order as request -} -``` - -Batch reads are **partially successful** — individual tags may have Bad quality while the overall response succeeds. If a tag read throws an exception, its VTQ is returned with Bad quality and current UTC timestamp. - -## Write Operations - -### Write (Single Tag) - -``` -WriteRequest { - session_id: string - tag: string - value: TypedValue // Native typed value (see TypedValue) -} - -WriteResponse { - success: bool - message: string -} -``` - -The client adapter's `ToTypedValue` method converts `object?` values to the appropriate `TypedValue` variant before transmission. See [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md#value-serialization) for the mapping table. - -### WriteBatch (Multiple Tags) - -``` -WriteItem { - tag: string - value: TypedValue -} - -WriteResult { - tag: string - success: bool - message: string -} - -WriteBatchRequest { - session_id: string - items: repeated WriteItem -} - -WriteBatchResponse { - success: bool // Overall success (all items must succeed) - message: string - results: repeated WriteResult // Per-item results -} -``` - -Batch writes are **all-or-nothing** at the reporting level — if any item fails, overall `success` is `false`. - -### WriteBatchAndWait (Atomic Write + Flag Polling) - -A compound operation: write values, then poll a flag tag until it matches an expected value or times out. - -``` -WriteBatchAndWaitRequest { - session_id: string - items: repeated WriteItem // Values to write (TypedValue) - flag_tag: string // Tag to poll after writes - flag_value: TypedValue // Expected value (typed comparison) - timeout_ms: int32 // Timeout in ms (default 5000 if ≤ 0) - poll_interval_ms: int32 // Poll interval in ms (default 100 if ≤ 0) -} - -WriteBatchAndWaitResponse { - success: bool // Overall operation success - message: string - write_results: repeated WriteResult // Per-item write results - flag_reached: bool // Whether flag matched before timeout - elapsed_ms: int32 // Total elapsed time -} -``` - -**Behavior:** -1. All writes execute first. If any write fails, the operation returns immediately with `success=false`. -2. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals. -3. Compares the read result's `TypedValue` against `flag_value`. -4. If flag matches before timeout: `success=true`, `flag_reached=true`. -5. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error). - -## Subscription (Server Streaming) - -### Subscribe - -``` -SubscribeRequest { - session_id: string - tags: repeated string // Tag addresses to monitor - sampling_ms: int32 // Backend sampling interval in milliseconds -} - -// Returns: stream of VtqMessage -``` - -**Behavior:** - -1. Server validates the session. Invalid session → `RpcException` with `StatusCode.Unauthenticated`. -2. Server registers monitored items on the backend (e.g., OPC UA subscriptions) for all requested tags. -3. On each value change, the server pushes a `VtqMessage` to the response stream. -4. The stream remains open indefinitely until: - - The client cancels (disposes the subscription). - - The server encounters an error (backend disconnect, etc.). - - The gRPC connection drops. -5. On stream termination, the client's `onStreamError` callback fires exactly once. - -**Client-side subscription lifecycle:** - -``` -ILmxSubscription subscription = await client.SubscribeAsync( - addresses: ["Motor.Speed", "Motor.Temperature"], - onUpdate: (tag, vtq) => { /* handle value change */ }, - onStreamError: () => { /* handle disconnect */ }); - -// Later: -await subscription.DisposeAsync(); // Cancels the stream -``` - -Disposing the subscription cancels the underlying `CancellationTokenSource`, which terminates the background stream-reading task and triggers server-side cleanup of monitored items. - -## Tag Addressing - -Tags are string addresses that identify data points. The proxy maps tag addresses to backend-specific identifiers. - -**LmxFakeProxy example** (OPC UA backend): - -Tag addresses are concatenated with a configurable prefix to form OPC UA node IDs: - -``` -Prefix: "ns=3;s=" -Tag: "Motor.Speed" -NodeId: "ns=3;s=Motor.Speed" -``` - -The prefix is configured at server startup via the `OPC_UA_PREFIX` environment variable. - -## Transport Details - -| Setting | Value | -|---------|-------| -| Protocol | gRPC over HTTP/2 | -| Default port | 50051 | -| TLS | Optional (controlled by `UseTls` connection parameter) | -| Metadata headers | `x-api-key` (sent on Connect call if API key configured) | - -### Connection Parameters - -The ScadaLink DCL configures LmxProxy connections via a string dictionary: - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `Host` | string | `"localhost"` | gRPC server hostname | -| `Port` | string (parsed as int) | `"50051"` | gRPC server port | -| `ApiKey` | string | (none) | API key for authentication | -| `SamplingIntervalMs` | string (parsed as int) | `"0"` | Backend sampling interval for subscriptions | -| `UseTls` | string (parsed as bool) | `"false"` | Use HTTPS instead of HTTP | - -## Error Handling - -| Operation | Error Mechanism | Client Behavior | -|-----------|----------------|-----------------| -| Connect | `success=false` in response | Throws `InvalidOperationException` | -| Read/ReadBatch | `success=false` in response | Throws `InvalidOperationException` | -| Write/WriteBatch | `success=false` in response | Throws `InvalidOperationException` | -| WriteBatchAndWait | `success=false` or `flag_reached=false` | Returns result (timeout is not an exception) | -| Subscribe (auth) | `RpcException` with `Unauthenticated` | Propagated to caller | -| Subscribe (stream) | Stream ends or gRPC error | `onStreamError` callback invoked; `sessionId` nullified | -| Any (disconnected) | Client checks `IsConnected` | Throws `InvalidOperationException("not connected")` | - -When a subscription stream ends unexpectedly, the client immediately nullifies its session ID, causing `IsConnected` to return `false`. The DCL adapter fires its `Disconnected` event, which triggers the reconnection cycle in the `DataConnectionActor`. - -## Implementation Files - -| Component | File | -|-----------|------| -| Proto definition | `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` | -| Client interface | `src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs` | -| Client implementation | `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` | -| DCL adapter | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs` | -| Client factory | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyClientFactory.cs` | -| Server implementation | `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` | -| Session manager | `infra/lmxfakeproxy/Sessions/SessionManager.cs` | -| Tag mapper | `infra/lmxfakeproxy/TagMapper.cs` | -| OPC UA bridge interface | `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` | -| OPC UA bridge impl | `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` | diff --git a/deprecated/lmxproxy/.gitignore b/deprecated/lmxproxy/.gitignore deleted file mode 100644 index fa6b520..0000000 --- a/deprecated/lmxproxy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -publish-v2/ diff --git a/deprecated/lmxproxy/CLAUDE.md b/deprecated/lmxproxy/CLAUDE.md deleted file mode 100644 index 1017bdd..0000000 --- a/deprecated/lmxproxy/CLAUDE.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What This Is - -LmxProxy is a gRPC proxy that bridges ScadaLink's Data Connection Layer to AVEVA System Platform via the ArchestrA MXAccess COM API. It has two projects: - -- **Host** (`ZB.MOM.WW.LmxProxy.Host`) — .NET Framework 4.8, x86-only Windows service. Hosts a gRPC server (Grpc.Core) that fronts an MxAccessClient talking to ArchestrA MXAccess. Runs as a Windows service via Topshelf. -- **Client** (`ZB.MOM.WW.LmxProxy.Client`) — .NET 10, AnyCPU library. Code-first gRPC client (protobuf-net.Grpc) consumed by ScadaLink's DCL. This is a NuGet-packable library. - -The two projects use **different gRPC stacks**: Host uses proto-file-generated code (`Grpc.Core` + `Grpc.Tools`), Client uses code-first contracts (`protobuf-net.Grpc` with `[DataContract]`/`[ServiceContract]` attributes). They are wire-compatible because both target the same `scada.ScadaService` gRPC service. - -## Build Commands - -```bash -dotnet build ZB.MOM.WW.LmxProxy.slnx # Build entire solution -dotnet build src/ZB.MOM.WW.LmxProxy.Host # Host only (requires x86 platform) -dotnet build src/ZB.MOM.WW.LmxProxy.Client # Client only -``` - -The Host project requires the `ArchestrA.MXAccess.dll` COM interop assembly in `lib/`. It targets x86 exclusively (MXAccess is 32-bit COM). - -## Architecture - -### Host Service Startup Chain - -`Program.Main` → Topshelf `HostFactory` → `LmxProxyService.Start()` which: -1. Validates configuration (`appsettings.json` bound to `LmxProxyConfiguration`) -2. Creates `MxAccessClient` (the `IScadaClient` impl that wraps ArchestrA.MXAccess COM) -3. Connects to MxAccess synchronously at startup -4. Starts connection monitor loop (auto-reconnect) -5. Creates `SubscriptionManager`, `SessionManager`, `PerformanceMetrics`, `ApiKeyService` -6. Creates `ScadaGrpcService` (the proto-generated service impl) with all dependencies -7. Starts Grpc.Core `Server` on configured port (default 50051) -8. Starts HTTP status web server (default port 8080) - -### Key Host Components - -- `MxAccessClient` — Partial class split across 6 files (Connection, ReadWrite, Subscription, EventHandlers, NestedTypes, main). Wraps `LMXProxyServer` COM object. Uses semaphores for concurrency control. -- `ScadaGrpcService` — Inherits proto-generated `ScadaService.ScadaServiceBase`. All RPCs validate session first, then delegate to `IScadaClient`. Values are string-serialized on the wire (v1 protocol). -- `SessionManager` — Tracks client sessions by GUID. -- `SubscriptionManager` — Manages MxAccess subscriptions, fans out updates via `System.Threading.Channels`. -- `ApiKeyInterceptor` — gRPC server interceptor for API key validation. - -### Client Architecture - -- `ILmxProxyClient` — Public interface for consumers. Connect/Read/Write/Subscribe/Dispose. -- `LmxProxyClient` — Partial class split across multiple files (Connection, Subscription, Metrics, etc.). Uses `protobuf-net.Grpc` code-first contracts (`IScadaService` in `Domain/ScadaContracts.cs`). -- `LmxProxyClientBuilder` — Fluent builder for configuring client instances. -- `Domain/ScadaContracts.cs` — All gRPC message types as `[DataContract]` POCOs and the `IScadaService` interface with `[ServiceContract]`. -- Value conversion: Client parses string values from wire using double → bool → string heuristic in `ConvertToVtq()`. Writes use `.ToString()` via `ConvertToString()`. - -### Protocol - -Proto definition: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` - -Currently v1 protocol (string-encoded values, string quality). A v2 protocol spec exists in `docs/lmxproxy_updates.md` that introduces `TypedValue` (protobuf oneof) and `QualityCode` (OPC UA status codes) — not yet implemented. - -RPCs: Connect, Disconnect, GetConnectionState, Read, ReadBatch, Write, WriteBatch, WriteBatchAndWait, Subscribe (server streaming), CheckApiKey. - -### Configuration - -Host configured via `appsettings.json` bound to `LmxProxyConfiguration`. Key sections: GrpcPort, Connection (timeouts, auto-reconnect), Subscription (channel capacity), Tls, WebServer, Serilog, RetryPolicies, HealthCheck. - -## Important Constraints - -- Host **must** target x86 and .NET Framework 4.8 (ArchestrA.MXAccess is 32-bit COM interop). -- Host uses `Grpc.Core` (the deprecated C-core gRPC library), not `Grpc.Net`. This is required because .NET 4.8 doesn't support `Grpc.Net.Server`. -- Client uses `Grpc.Net.Client` and targets .NET 10 — it runs in the ScadaLink central/site clusters. -- The solution file is `.slnx` format (XML-based, not the older text format). diff --git a/deprecated/lmxproxy/ZB.MOM.WW.LmxProxy.slnx b/deprecated/lmxproxy/ZB.MOM.WW.LmxProxy.slnx deleted file mode 100644 index ca75a8e..0000000 --- a/deprecated/lmxproxy/ZB.MOM.WW.LmxProxy.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/deprecated/lmxproxy/docs/deviations.md b/deprecated/lmxproxy/docs/deviations.md deleted file mode 100644 index 0f32ee1..0000000 --- a/deprecated/lmxproxy/docs/deviations.md +++ /dev/null @@ -1,107 +0,0 @@ -# LmxProxy v2 Rebuild — Deviations & Key Technical Decisions - -Decisions made during implementation that differ from or extend the original plan. - -## 1. Grpc.Tools downgraded to 2.68.1 - -**Plan specified**: Grpc.Tools 2.71.0 -**Actual**: 2.68.1 -**Why**: protoc.exe from 2.71.0 crashes with access violation (exit code 0xC0000005) on windev (Windows 10, x64). The 2.68.1 version works reliably. -**How to apply**: If upgrading Grpc.Tools in the future, test protoc on windev first. - -## 2. STA threading — three iterations - -**Plan specified**: Dedicated STA thread with `BlockingCollection` dispatch queue and `Application.DoEvents()` message pump. -**Iteration 1 (failed)**: `StaDispatchThread` with `BlockingCollection.Take()` + `Application.DoEvents()`. Failed because `Take()` blocked the STA thread, preventing the message pump from running. COM callbacks never fired. -**Iteration 2 (partial)**: Replaced with `Task.Run` on thread pool (MTA). `OnDataChange` worked (MxAccess fires it on its own threads), but `OnWriteComplete` never fired (needs message-pump-based marshaling). Writes used fire-and-forget as a workaround. -**Iteration 3 (current)**: `StaComThread` with Win32 `GetMessage`/`DispatchMessage` loop. Work dispatched via `PostThreadMessage(WM_APP)` which wakes the message pump. COM callbacks (`OnDataChange`, `OnWriteComplete`) are delivered between work items via `DispatchMessage`. All COM objects created and called on this single STA thread. -**How to apply**: All MxAccess COM calls must go through `_staThread.RunAsync()`. Never call COM objects directly from thread pool threads. See `docs/sta_gap.md` for the full design analysis. - -## 3. TypedValue property-level `_setCase` tracking - -**Plan specified**: `GetValueCase()` heuristic checking non-default values (e.g., `if (BoolValue) return BoolValue`). -**Actual**: Each property setter records `_setCase = TypedValueCase.XxxValue`, and `GetValueCase()` returns `_setCase` directly. -**Why**: protobuf-net code-first has no native `oneof` support. The heuristic approach can't distinguish "field not set" from "field set to default value" (e.g., `BoolValue = false`, `DoubleValue = 0.0`, `Int32Value = 0`). Since protobuf-net calls property setters during deserialization, tracking in the setter correctly identifies which field was deserialized. -**How to apply**: Always use `GetValueCase()` to determine which TypedValue field is set, never check for non-default values directly. - -## 4. API key sent via HTTP header (DelegatingHandler) - -**Plan specified**: API key sent in `ConnectRequest.ApiKey` field (request body). -**Actual**: API key sent as `x-api-key` HTTP header on every gRPC request via `ApiKeyDelegatingHandler`, in addition to the request body. -**Why**: The Host's `ApiKeyInterceptor` validates the `x-api-key` gRPC metadata header before any RPC handler executes. protobuf-net.Grpc's `CreateGrpcService()` doesn't expose per-call metadata, so the header must be added at the HTTP transport level. A `DelegatingHandler` wrapping the `SocketsHttpHandler` adds it to all outgoing requests. -**How to apply**: The `GrpcChannelFactory.CreateChannel()` accepts an optional `apiKey` parameter. The `LmxProxyClient` passes it during channel creation in `ConnectAsync`. - -## 5. v2 test deployment on port 50100 - -**Plan specified**: Port 50052 for v2 test deployment. -**Actual**: Port 50100. -**Why**: Ports 50049–50060 are used by MxAccess internal COM connections (established TCP pairs between the COM client and server). Port 50052 was occupied by an ephemeral MxAccess connection from the v1 service. -**How to apply**: When deploying alongside v1, use ports above 50100 to avoid MxAccess ephemeral port range. - -## 6. CheckApiKey validates request body key - -**Plan specified**: Not explicitly defined — the interceptor validates the header key. -**Actual**: `CheckApiKey` RPC validates the key from the *request body* (`request.ApiKey`) against `ApiKeyService`, not the header key. -**Why**: The `x-api-key` header always carries the caller's valid key (for interceptor auth). The `CheckApiKey` RPC is designed for clients to test whether a *different* key is valid, so it must check the body key independently. -**How to apply**: `ScadaGrpcService` receives `ApiKeyService` as an optional constructor parameter. - -## 7. OnWriteComplete callback — resolved via STA message pump - -**Plan specified**: Wait for `OnWriteComplete` COM callback to confirm write success. -**History**: Initially implemented as fire-and-forget because `OnWriteComplete` never fired — the Host had no Windows message pump to deliver the COM callback. See `docs/sta_gap.md` for the full analysis. -**Resolution**: `StaComThread` (a dedicated STA thread with a Win32 `GetMessage`/`DispatchMessage` loop) was introduced, providing a proper message pump. All COM operations are now dispatched to this thread via `PostThreadMessage(WM_APP)`. The message pump delivers `OnWriteComplete` callbacks between work items. -**Current behavior**: Write dispatches `_lmxProxy.Write()` on the STA thread, registers a `TaskCompletionSource` in `_pendingWrites`, then awaits the callback with a timeout. `OnWriteComplete` resolves or rejects the TCS with `MxStatusMapper` error details. If the callback doesn't arrive within the write timeout, falls back to success (fire-and-forget safety net). Clean up (UnAdvise + RemoveItem) happens on the STA thread after the callback or timeout. -**How to apply**: Writes now get real confirmation from MxAccess. Secured write (1012) and verified write (1013) rejections are surfaced as exceptions via `OnWriteComplete`. The timeout fallback ensures writes don't hang if the callback is delayed. - -## 8. SubscriptionManager must create MxAccess COM subscriptions - -**Plan specified**: SubscriptionManager manages per-client channels and routes updates from MxAccess. -**Actual**: SubscriptionManager must also call `IScadaClient.SubscribeAsync()` to create the underlying COM subscriptions when a tag is first subscribed, and dispose them when the last client unsubscribes. -**Why**: The Phase 2 implementation tracked client-to-tag routing in internal dictionaries but never called `MxAccessClient.SubscribeAsync()` to create the actual MxAccess COM subscriptions (`AddItem` + `AdviseSupervisory`). Without the COM subscription, `OnDataChange` never fired and no updates were delivered to clients. This caused the `Subscribe_ReceivesUpdates` integration test to receive 0 updates over 30 seconds. -**How to apply**: `SubscriptionManager.SubscribeAsync()` collects newly-seen tags (those without an existing `TagSubscription`) and **awaits** `_scadaClient.SubscribeAsync()` for them, passing `OnTagValueChanged` as the callback. The await ensures the COM subscription is fully established before the channel reader is returned — this prevents a race where the initial `OnDataChange` (first value delivery after `AdviseSupervisory`) fires before the gRPC stream handler starts reading. Previously this was fire-and-forget (`_ = CreateMxAccessSubscriptionsAsync()`), causing intermittent `Subscribe_ReceivesUpdates` test failures (0 updates in 30s). - ---- - -# Known Gaps - -## Gap 1: No active connection health probing - -**Status**: Resolved (2026-03-22, commit `a6c01d7`). - -**Problem**: `MxAccessClient.IsConnected` checks `_connectionState == Connected && _connectionHandle > 0`. When the AVEVA platform (aaBootstrap) is killed or restarted, the MxAccess COM object and handle remain valid in memory — `IsConnected` stays `true`. The auto-reconnect monitor loop (`MonitorConnectionAsync`) only triggers when `IsConnected` is `false`, so it never attempts reconnection. - -**Observed behavior** (tested 2026-03-22): After killing the aaBootstrap process, all reads returned null values with Bad quality indefinitely. The monitor loop kept seeing `IsConnected == true` and never reconnected. - -**Fix implemented**: The monitor loop now actively probes the connection using `ProbeConnectionAsync`, which reads a configurable test tag and classifies the result as `Healthy`, `TransportFailure`, or `DataDegraded`. -- `TransportFailure` for N consecutive probes (default 3) → forced disconnect + full reconnect (new COM object, `Register`, `RecreateStoredSubscriptionsAsync`) -- `DataDegraded` → stay connected, back off probe interval to 30s, report degraded status (platform objects may be stopped) -- `Healthy` → reset counters, resume normal interval - -**Verified** (tested 2026-03-22): Graceful platform stop via SMC → 4 failed probes → automatic reconnect → reads restored within ~60 seconds. All 17 integration tests pass after recovery. Subscribed clients receive `Bad_NotConnected` quality during outage, then Good quality resumes automatically. - -**Configuration** (`appsettings.json` → `HealthCheck` section): -- `TestTagAddress`: Tag to probe (default `TestChildObject.TestBool`) -- `ProbeTimeoutMs`: Probe read timeout (default 5000ms) -- `MaxConsecutiveTransportFailures`: Failures before forced reconnect (default 3) -- `DegradedProbeIntervalMs`: Probe interval in degraded mode (default 30000ms) - -## Gap 2: Stale SubscriptionManager handles after reconnect - -**Status**: Resolved (2026-03-22, commit `a6c01d7`). - -**Problem**: `SubscriptionManager` stored `IAsyncDisposable` handles from `_scadaClient.SubscribeAsync()` in `_mxAccessHandles`. After a reconnect, `MxAccessClient.RecreateStoredSubscriptionsAsync()` recreated COM subscriptions internally but `SubscriptionManager._mxAccessHandles` still held stale handles. Additionally, a batch subscription stored the same handle for every address — disposing one address would dispose the entire batch. - -**Fix implemented**: Removed `_mxAccessHandles` entirely. `SubscriptionManager` no longer tracks COM subscription handles. Ownership is cleanly split: -- `SubscriptionManager` owns client routing and ref-counting only -- `MxAccessClient` owns COM subscription lifecycle via `_storedSubscriptions` and `_addressToHandle` -- Unsubscribe uses `_scadaClient.UnsubscribeByAddressAsync(addresses)` — address-based, resolves to current handles regardless of reconnect history - -## Gap 3: AVEVA objects don't auto-start after platform crash - -**Status**: Documented. Platform behavior, not an LmxProxy issue. - -**Observed behavior** (tested 2026-03-22): After killing aaBootstrap, the service auto-restarted (via Windows SCM recovery or Watchdog) within seconds. However, the ArchestrA objects (TestChildObject) did not automatically start. MxAccess connected successfully (`Register()` returned a valid handle) but all tag reads returned null values with Bad quality for 40+ minutes. Objects only recovered after manual restart via the System Management Console (SMC). - -**Implication for LmxProxy**: Even with Gap 1 fixed (active probing + reconnect), reads will still return Bad quality until the platform objects are running. LmxProxy cannot fix this — it's a platform-level recovery issue. The health check should report this clearly: "MxAccess connected but tag quality is Bad — platform objects may need manual restart." - -**Timeline**: aaBootstrap restart from SMC (graceful) takes ~5 minutes for objects to come back. aaBootstrap kill (crash) requires manual object restart via SMC — objects do not auto-recover. diff --git a/deprecated/lmxproxy/docs/lmxproxy_protocol.md b/deprecated/lmxproxy/docs/lmxproxy_protocol.md deleted file mode 100644 index 7f645ed..0000000 --- a/deprecated/lmxproxy/docs/lmxproxy_protocol.md +++ /dev/null @@ -1,360 +0,0 @@ -# LmxProxy Protocol Specification - -The LmxProxy protocol is a gRPC-based SCADA read/write interface for bridging ScadaLink's Data Connection Layer to devices via an intermediary proxy server (LmxProxy). The proxy translates LmxProxy protocol operations into backend device calls (e.g., OPC UA). All communication uses HTTP/2 gRPC with Protocol Buffers. - -## Service Definition - -```protobuf -syntax = "proto3"; -package scada; - -service ScadaService { - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} -``` - -Proto file location: `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` - -## Connection Lifecycle - -### Session Model - -Every client must call `Connect` before performing any read, write, or subscribe operation. The server returns a session ID (32-character hex GUID) that must be included in all subsequent requests. Sessions persist until `Disconnect` is called or the server restarts — there is no idle timeout. - -### Authentication - -API key authentication is optional, controlled by server configuration: - -- **If required**: The `Connect` RPC fails with `success=false` if the API key doesn't match. -- **If not required**: All API keys are accepted (including empty). -- The API key is sent both in the `ConnectRequest.api_key` field and as an `x-api-key` gRPC metadata header on the `Connect` call. - -### Connect - -``` -ConnectRequest { - client_id: string // Client identifier (e.g., "ScadaLink-{guid}") - api_key: string // API key for authentication (empty if none) -} - -ConnectResponse { - success: bool // Whether connection succeeded - message: string // Status message - session_id: string // 32-char hex GUID (only valid if success=true) -} -``` - -The client generates `client_id` as `"ScadaLink-{Guid:N}"` for uniqueness. - -### Disconnect - -``` -DisconnectRequest { - session_id: string -} - -DisconnectResponse { - success: bool - message: string -} -``` - -Best-effort — the client calls disconnect but does not retry on failure. - -### GetConnectionState - -``` -GetConnectionStateRequest { - session_id: string -} - -GetConnectionStateResponse { - is_connected: bool - client_id: string - connected_since_utc_ticks: int64 // DateTime.UtcNow.Ticks at connect time -} -``` - -### CheckApiKey - -``` -CheckApiKeyRequest { - api_key: string -} - -CheckApiKeyResponse { - is_valid: bool - message: string -} -``` - -Standalone API key validation without creating a session. - -## Value-Timestamp-Quality (VTQ) - -The core data structure for all read and subscription results: - -``` -VtqMessage { - tag: string // Tag address - value: string // Value encoded as string (see Value Encoding) - timestamp_utc_ticks: int64 // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - quality: string // "Good", "Uncertain", or "Bad" -} -``` - -### Value Encoding - -All values are transmitted as strings on the wire. Both client and server use the same parsing order: - -| Wire String | Parsed Type | Example | -|-------------|------------|---------| -| Numeric (double-parseable) | `double` | `"42.5"` → `42.5` | -| `"true"` / `"false"` (case-insensitive) | `bool` | `"True"` → `true` | -| Everything else | `string` | `"Running"` → `"Running"` | -| Empty string | `null` | `""` → `null` | - -For write operations, values are converted to strings via `.ToString()` before transmission. - -Arrays and lists are JSON-serialized (e.g., `[1,2,3]`). - -### Quality Codes - -Quality is transmitted as a case-insensitive string: - -| Wire Value | Meaning | OPC UA Status Code | -|-----------|---------|-------------------| -| `"Good"` | Value is reliable | `0x00000000` (StatusCode == 0) | -| `"Uncertain"` | Value may not be current | Non-zero, high bit clear | -| `"Bad"` | Value is unreliable or unavailable | High bit set (`0x80000000`) | - -A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp. - -### Timestamps - -- All timestamps are UTC. -- Encoded as `int64` representing `DateTime.Ticks` (100-nanosecond intervals since 0001-01-01 00:00:00 UTC). -- Client reconstructs via `new DateTime(ticks, DateTimeKind.Utc)`. - -## Read Operations - -### Read (Single Tag) - -``` -ReadRequest { - session_id: string // Valid session ID - tag: string // Tag address -} - -ReadResponse { - success: bool // Whether read succeeded - message: string // Error message if failed - vtq: VtqMessage // Value-timestamp-quality result -} -``` - -### ReadBatch (Multiple Tags) - -``` -ReadBatchRequest { - session_id: string - tags: repeated string // Tag addresses -} - -ReadBatchResponse { - success: bool // false if any tag failed - message: string // Error message - vtqs: repeated VtqMessage // Results in same order as request -} -``` - -Batch reads are **partially successful** — individual tags may have Bad quality while the overall response succeeds. If a tag read throws an exception, its VTQ is returned with Bad quality and current UTC timestamp. - -## Write Operations - -### Write (Single Tag) - -``` -WriteRequest { - session_id: string - tag: string - value: string // Value as string (parsed server-side) -} - -WriteResponse { - success: bool - message: string -} -``` - -### WriteBatch (Multiple Tags) - -``` -WriteItem { - tag: string - value: string -} - -WriteResult { - tag: string - success: bool - message: string -} - -WriteBatchRequest { - session_id: string - items: repeated WriteItem -} - -WriteBatchResponse { - success: bool // Overall success (all items must succeed) - message: string - results: repeated WriteResult // Per-item results -} -``` - -Batch writes are **all-or-nothing** at the reporting level — if any item fails, overall `success` is `false`. - -### WriteBatchAndWait (Atomic Write + Flag Polling) - -A compound operation: write values, then poll a flag tag until it matches an expected value or times out. - -``` -WriteBatchAndWaitRequest { - session_id: string - items: repeated WriteItem // Values to write - flag_tag: string // Tag to poll after writes - flag_value: string // Expected value (string comparison) - timeout_ms: int32 // Timeout in ms (default 5000 if ≤ 0) - poll_interval_ms: int32 // Poll interval in ms (default 100 if ≤ 0) -} - -WriteBatchAndWaitResponse { - success: bool // Overall operation success - message: string - write_results: repeated WriteResult // Per-item write results - flag_reached: bool // Whether flag matched before timeout - elapsed_ms: int32 // Total elapsed time -} -``` - -**Behavior:** -1. All writes execute first. If any write fails, the operation returns immediately with `success=false`. -2. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals. -3. Compares `readResult.Value?.ToString() == flag_value` (case-sensitive string comparison). -4. If flag matches before timeout: `success=true`, `flag_reached=true`. -5. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error). - -## Subscription (Server Streaming) - -### Subscribe - -``` -SubscribeRequest { - session_id: string - tags: repeated string // Tag addresses to monitor - sampling_ms: int32 // Backend sampling interval in milliseconds -} - -// Returns: stream of VtqMessage -``` - -**Behavior:** - -1. Server validates the session. Invalid session → `RpcException` with `StatusCode.Unauthenticated`. -2. Server registers monitored items on the backend (e.g., OPC UA subscriptions) for all requested tags. -3. On each value change, the server pushes a `VtqMessage` to the response stream. -4. The stream remains open indefinitely until: - - The client cancels (disposes the subscription). - - The server encounters an error (backend disconnect, etc.). - - The gRPC connection drops. -5. On stream termination, the client's `onStreamError` callback fires exactly once. - -**Client-side subscription lifecycle:** - -``` -ILmxSubscription subscription = await client.SubscribeAsync( - addresses: ["Motor.Speed", "Motor.Temperature"], - onUpdate: (tag, vtq) => { /* handle value change */ }, - onStreamError: () => { /* handle disconnect */ }); - -// Later: -await subscription.DisposeAsync(); // Cancels the stream -``` - -Disposing the subscription cancels the underlying `CancellationTokenSource`, which terminates the background stream-reading task and triggers server-side cleanup of monitored items. - -## Tag Addressing - -Tags are string addresses that identify data points. The proxy maps tag addresses to backend-specific identifiers. - -**LmxFakeProxy example** (OPC UA backend): - -Tag addresses are concatenated with a configurable prefix to form OPC UA node IDs: - -``` -Prefix: "ns=3;s=" -Tag: "Motor.Speed" -NodeId: "ns=3;s=Motor.Speed" -``` - -The prefix is configured at server startup via the `OPC_UA_PREFIX` environment variable. - -## Transport Details - -| Setting | Value | -|---------|-------| -| Protocol | gRPC over HTTP/2 | -| Default port | 50051 | -| TLS | Optional (controlled by `UseTls` connection parameter) | -| Metadata headers | `x-api-key` (sent on Connect call if API key configured) | - -### Connection Parameters - -The ScadaLink DCL configures LmxProxy connections via a string dictionary: - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `Host` | string | `"localhost"` | gRPC server hostname | -| `Port` | string (parsed as int) | `"50051"` | gRPC server port | -| `ApiKey` | string | (none) | API key for authentication | -| `SamplingIntervalMs` | string (parsed as int) | `"0"` | Backend sampling interval for subscriptions | -| `UseTls` | string (parsed as bool) | `"false"` | Use HTTPS instead of HTTP | - -## Error Handling - -| Operation | Error Mechanism | Client Behavior | -|-----------|----------------|-----------------| -| Connect | `success=false` in response | Throws `InvalidOperationException` | -| Read/ReadBatch | `success=false` in response | Throws `InvalidOperationException` | -| Write/WriteBatch | `success=false` in response | Throws `InvalidOperationException` | -| WriteBatchAndWait | `success=false` or `flag_reached=false` | Returns result (timeout is not an exception) | -| Subscribe (auth) | `RpcException` with `Unauthenticated` | Propagated to caller | -| Subscribe (stream) | Stream ends or gRPC error | `onStreamError` callback invoked; `sessionId` nullified | -| Any (disconnected) | Client checks `IsConnected` | Throws `InvalidOperationException("not connected")` | - -When a subscription stream ends unexpectedly, the client immediately nullifies its session ID, causing `IsConnected` to return `false`. The DCL adapter fires its `Disconnected` event, which triggers the reconnection cycle in the `DataConnectionActor`. - -## Implementation Files - -| Component | File | -|-----------|------| -| Proto definition | `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` | -| Client interface | `src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs` | -| Client implementation | `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` | -| DCL adapter | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs` | -| Client factory | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyClientFactory.cs` | -| Server implementation | `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` | -| Session manager | `infra/lmxfakeproxy/Sessions/SessionManager.cs` | -| Tag mapper | `infra/lmxfakeproxy/TagMapper.cs` | -| OPC UA bridge interface | `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` | -| OPC UA bridge impl | `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` | diff --git a/deprecated/lmxproxy/docs/lmxproxy_updates.md b/deprecated/lmxproxy/docs/lmxproxy_updates.md deleted file mode 100644 index 66fb279..0000000 --- a/deprecated/lmxproxy/docs/lmxproxy_updates.md +++ /dev/null @@ -1,646 +0,0 @@ -# LmxProxy Protocol v2 — OPC UA Alignment - -This document specifies all changes to the LmxProxy gRPC protocol to align it with OPC UA semantics. The changes replace string-serialized values with typed values and simple quality strings with OPC UA-style status codes. - -**Baseline:** `lmxproxy_protocol.md` (v1 protocol spec) -**Strategy:** Clean break — all clients and servers updated simultaneously. No backward compatibility layer. - ---- - -## 1. Change Summary - -| Message / Field | v1 Type | v2 Type | Breaking? | -|-----------------|---------|---------|-----------| -| `VtqMessage.value` | `string` | `TypedValue` | Yes | -| `VtqMessage.quality` | `string` | `QualityCode` | Yes | -| `WriteRequest.value` | `string` | `TypedValue` | Yes | -| `WriteItem.value` | `string` | `TypedValue` | Yes | -| `WriteBatchAndWaitRequest.flag_value` | `string` | `TypedValue` | Yes | - -**Unchanged messages:** `ConnectRequest`, `ConnectResponse`, `DisconnectRequest`, `DisconnectResponse`, `GetConnectionStateRequest`, `GetConnectionStateResponse`, `CheckApiKeyRequest`, `CheckApiKeyResponse`, `ReadRequest`, `ReadBatchRequest`, `SubscribeRequest`, `WriteResponse`, `WriteBatchResponse`, `WriteBatchAndWaitResponse`, `WriteResult`. - -**Unchanged RPCs:** The `ScadaService` definition is identical — same RPC names, same request/response pairing. Only the internal message shapes change. - ---- - -## 2. Complete Updated Proto File - -```protobuf -syntax = "proto3"; -package scada; - -// ============================================================ -// Service Definition (unchanged) -// ============================================================ - -service ScadaService { - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} - -// ============================================================ -// NEW: Typed Value System -// ============================================================ - -// Replaces the v1 string-encoded value field. -// Exactly one field will be set. An unset oneof represents null. -message TypedValue { - oneof value { - bool bool_value = 1; - int32 int32_value = 2; - int64 int64_value = 3; - float float_value = 4; - double double_value = 5; - string string_value = 6; - bytes bytes_value = 7; // byte[] - int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - ArrayValue array_value = 9; // arrays of primitives - } -} - -// Container for typed arrays. Exactly one field will be set. -message ArrayValue { - oneof values { - BoolArray bool_values = 1; - Int32Array int32_values = 2; - Int64Array int64_values = 3; - FloatArray float_values = 4; - DoubleArray double_values = 5; - StringArray string_values = 6; - } -} - -message BoolArray { repeated bool values = 1; } -message Int32Array { repeated int32 values = 1; } -message Int64Array { repeated int64 values = 1; } -message FloatArray { repeated float values = 1; } -message DoubleArray { repeated double values = 1; } -message StringArray { repeated string values = 1; } - -// ============================================================ -// NEW: OPC UA-Style Quality Codes -// ============================================================ - -// Replaces the v1 string quality field ("Good", "Bad", "Uncertain"). -message QualityCode { - uint32 status_code = 1; // OPC UA-compatible numeric status code - string symbolic_name = 2; // Human-readable name (e.g., "Good", "BadSensorFailure") -} - -// ============================================================ -// Connection Lifecycle (unchanged) -// ============================================================ - -message ConnectRequest { - string client_id = 1; - string api_key = 2; -} - -message ConnectResponse { - bool success = 1; - string message = 2; - string session_id = 3; -} - -message DisconnectRequest { - string session_id = 1; -} - -message DisconnectResponse { - bool success = 1; - string message = 2; -} - -message GetConnectionStateRequest { - string session_id = 1; -} - -message GetConnectionStateResponse { - bool is_connected = 1; - string client_id = 2; - int64 connected_since_utc_ticks = 3; -} - -message CheckApiKeyRequest { - string api_key = 1; -} - -message CheckApiKeyResponse { - bool is_valid = 1; - string message = 2; -} - -// ============================================================ -// Value-Timestamp-Quality (CHANGED) -// ============================================================ - -message VtqMessage { - string tag = 1; // Tag address (unchanged) - TypedValue value = 2; // CHANGED: typed value instead of string - int64 timestamp_utc_ticks = 3; // UTC DateTime.Ticks (unchanged) - QualityCode quality = 4; // CHANGED: structured quality instead of string -} - -// ============================================================ -// Read Operations (request unchanged, response uses new VtqMessage) -// ============================================================ - -message ReadRequest { - string session_id = 1; - string tag = 2; -} - -message ReadResponse { - bool success = 1; - string message = 2; - VtqMessage vtq = 3; // Uses updated VtqMessage with TypedValue + QualityCode -} - -message ReadBatchRequest { - string session_id = 1; - repeated string tags = 2; -} - -message ReadBatchResponse { - bool success = 1; - string message = 2; - repeated VtqMessage vtqs = 3; // Uses updated VtqMessage -} - -// ============================================================ -// Write Operations (CHANGED: TypedValue instead of string) -// ============================================================ - -message WriteRequest { - string session_id = 1; - string tag = 2; - TypedValue value = 3; // CHANGED from string -} - -message WriteResponse { - bool success = 1; - string message = 2; -} - -message WriteItem { - string tag = 1; - TypedValue value = 2; // CHANGED from string -} - -message WriteResult { - string tag = 1; - bool success = 2; - string message = 3; -} - -message WriteBatchRequest { - string session_id = 1; - repeated WriteItem items = 2; -} - -message WriteBatchResponse { - bool success = 1; - string message = 2; - repeated WriteResult results = 3; -} - -// ============================================================ -// WriteBatchAndWait (CHANGED: TypedValue for items and flag) -// ============================================================ - -message WriteBatchAndWaitRequest { - string session_id = 1; - repeated WriteItem items = 2; // Uses updated WriteItem with TypedValue - string flag_tag = 3; - TypedValue flag_value = 4; // CHANGED from string — type-aware comparison - int32 timeout_ms = 5; - int32 poll_interval_ms = 6; -} - -message WriteBatchAndWaitResponse { - bool success = 1; - string message = 2; - repeated WriteResult write_results = 3; - bool flag_reached = 4; - int32 elapsed_ms = 5; -} - -// ============================================================ -// Subscription (request unchanged, stream uses new VtqMessage) -// ============================================================ - -message SubscribeRequest { - string session_id = 1; - repeated string tags = 2; - int32 sampling_ms = 3; -} - -// Returns: stream of VtqMessage (updated with TypedValue + QualityCode) -``` - ---- - -## 3. Detailed Change Specifications - -### 3.1 Typed Value Representation - -**What changed:** The `string value` field throughout the protocol is replaced by `TypedValue`, a protobuf `oneof` that carries the value in its native type. - -**v1 behavior (removed):** -- All values serialized to string via `.ToString()` -- Client-side parsing heuristic: numeric → bool → string → null -- Arrays JSON-serialized as strings (e.g., `"[1,2,3]"`) -- Empty string treated as null - -**v2 behavior:** -- Values transmitted in their native protobuf type -- No parsing ambiguity — the `oneof` case tells you the type -- Arrays use dedicated repeated-field messages (`Int32Array`, `FloatArray`, etc.) -- Null represented by an unset `oneof` (no field selected in `TypedValue`) -- `datetime_value` uses `int64` UTC Ticks (same wire encoding as v1 timestamps, but now semantically typed as a DateTime value rather than a string) - -**Null handling:** - -| Scenario | v1 | v2 | -|----------|----|----| -| Null value | `value = ""` (empty string) | `TypedValue` with no `oneof` case set | -| Missing VTQ | Treated as Bad quality, null value | Same — Bad quality, unset `TypedValue` | - -**Type mapping from internal tag model:** - -| Tag Data Type | TypedValue Field | Notes | -|---------------|-----------------|-------| -| `bool` | `bool_value` | | -| `int32` | `int32_value` | | -| `int64` | `int64_value` | | -| `float` | `float_value` | | -| `double` | `double_value` | | -| `string` | `string_value` | | -| `byte[]` | `bytes_value` | | -| `DateTime` | `datetime_value` | UTC Ticks as int64 | -| `float[]` | `array_value.float_values` | | -| `int32[]` | `array_value.int32_values` | | -| Other arrays | Corresponding `ArrayValue` field | | - -### 3.2 OPC UA-Style Quality Codes - -**What changed:** The `string quality` field (one of `"Good"`, `"Uncertain"`, `"Bad"`) is replaced by `QualityCode` containing a numeric OPC UA status code and a human-readable symbolic name. - -**v1 behavior (removed):** -- Quality as case-insensitive string: `"Good"`, `"Uncertain"`, `"Bad"` -- No sub-codes — all failures were just `"Bad"` - -**v2 behavior:** -- `status_code` is a `uint32` matching OPC UA `StatusCode` bit layout -- `symbolic_name` is the human-readable equivalent (for logging, debugging, display) -- Category derived from high bits: `0x00xxxxxx` = Good, `0x40xxxxxx` = Uncertain, `0x80xxxxxx` = Bad - -**Supported quality codes:** - -The quality codes below are filtered to those actively used by AVEVA System Platform, InTouch, and OI Server/DAServer (per AVEVA Tech Note TN1305). AVEVA's ecosystem maps OPC DA quality codes to OPC UA status codes when communicating over OPC UA. This table includes the OPC UA equivalents for the AVEVA-relevant quality states. - -**Good Quality:** - -| Symbolic Name | Status Code | AVEVA OPC DA Hex | AVEVA Description | -|---------------|-------------|------------------|-------------------| -| `Good` | `0x00000000` | `0x00C0` | Value is reliable, non-specific | -| `GoodLocalOverride` | `0x00D80000` | `0x00D8` | Value has been manually overridden; input disconnected | - -**Uncertain Quality:** - -| Symbolic Name | Status Code | AVEVA OPC DA Hex | AVEVA Description | -|---------------|-------------|------------------|-------------------| -| `UncertainLastUsableValue` | `0x40900000` | `0x0044` | External source stopped writing; value is stale | -| `UncertainSensorNotAccurate` | `0x42390000` | `0x0050` | Sensor out of calibration or clamped at limit | -| `UncertainEngineeringUnitsExceeded` | `0x40540000` | `0x0054` | Value is outside defined engineering limits | -| `UncertainSubNormal` | `0x40580000` | `0x0058` | Derived from multiple sources with insufficient good sources | - -**Bad Quality:** - -| Symbolic Name | Status Code | AVEVA OPC DA Hex | AVEVA Description | -|---------------|-------------|------------------|-------------------| -| `Bad` | `0x80000000` | `0x0000` | Non-specific bad; value is not useful | -| `BadConfigurationError` | `0x80040000` | `0x0004` | Server-specific configuration problem (e.g., item deleted) | -| `BadNotConnected` | `0x808A0000` | `0x0008` | Input not logically connected to a source | -| `BadDeviceFailure` | `0x806B0000` | `0x000C` | Device failure detected | -| `BadSensorFailure` | `0x806D0000` | `0x0010` | Sensor failure detected | -| `BadLastKnownValue` | `0x80050000` | `0x0014` | Communication failed; last known value available (check timestamp age) | -| `BadCommunicationFailure` | `0x80050000` | `0x0018` | Communication failed; no last known value available | -| `BadOutOfService` | `0x808F0000` | `0x001C` | Block is off-scan or locked; item/group is inactive | - -**Notes:** -- AVEVA OPC DA quality codes use a 16-bit structure: 2 bits major (Good/Bad/Uncertain), 4 bits minor (sub-status), 2 bits limit (Not Limited, Low, High, Constant). The OPC UA status codes above are the standard UA equivalents. -- The limit bits (Not Limited `0x00`, Low Limited `0x01`, High Limited `0x02`, Constant `0x03`) are appended to any quality code. For example, `Good + High Limited` = `0x00C2` in OPC DA. In OPC UA, limits are conveyed via separate status code bits but the base code remains the same. -- AVEVA's "Initializing" state (seen when OI Server is still establishing communication) maps to `Bad` with no sub-code in OPC DA (`0x0000`). In OPC UA this is `BadWaitingForInitialData` (`0x80320000`). -- This is the minimum set needed to simulate realistic AVEVA System Platform behavior. Additional OPC UA codes can be added if specific simulation scenarios require them. - -**Category helper logic (C#):** - -```csharp -public static string GetCategory(uint statusCode) => statusCode switch -{ - _ when (statusCode & 0xC0000000) == 0x00000000 => "Good", - _ when (statusCode & 0xC0000000) == 0x40000000 => "Uncertain", - _ when (statusCode & 0xC0000000) == 0x80000000 => "Bad", - _ => "Unknown" -}; - -public static bool IsGood(uint statusCode) => (statusCode & 0xC0000000) == 0x00000000; -public static bool IsBad(uint statusCode) => (statusCode & 0xC0000000) == 0x80000000; -``` - -### 3.3 WriteBatchAndWait Flag Comparison - -**What changed:** `flag_value` is now `TypedValue` instead of `string`. The server uses type-aware equality comparison instead of string comparison. - -**v1 behavior (removed):** -```csharp -// v1: string comparison -bool matched = readResult.Value?.ToString() == request.FlagValue; -``` - -**v2 behavior:** -```csharp -// v2: type-aware comparison -bool matched = TypedValueEquals(readResult.TypedValue, request.FlagValue); -``` - -**Comparison rules:** -- Both values must have the same `oneof` case (same type). Mismatched types are never equal. -- Numeric comparison uses the native type's equality (no floating-point string round-trip issues). -- String comparison is case-sensitive (unchanged from v1). -- Bool comparison is direct equality. -- Null (unset `oneof`) equals null. Null does not equal any set value. -- Array comparison: element-by-element equality, same length required. -- `datetime_value` compared as `int64` equality (tick-level precision). - ---- - -## 4. Behavioral Changes - -### 4.1 Read Operations - -No RPC signature changes. The returned `VtqMessage` now uses `TypedValue` and `QualityCode` instead of strings. - -**v1 client code:** -```csharp -var response = await client.ReadAsync(new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }); -double value = double.Parse(response.Vtq.Value); // string → double -bool isGood = response.Vtq.Quality.Equals("Good", ...); // string comparison -``` - -**v2 client code:** -```csharp -var response = await client.ReadAsync(new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }); -double value = response.Vtq.Value.DoubleValue; // direct typed access -bool isGood = response.Vtq.Quality.StatusCode == 0x00000000; // numeric comparison -// or: bool isGood = IsGood(response.Vtq.Quality.StatusCode); // helper method -``` - -### 4.2 Write Operations - -Client must construct `TypedValue` instead of converting to string. - -**v1 client code:** -```csharp -await client.WriteAsync(new WriteRequest -{ - SessionId = sid, - Tag = "Motor.Speed", - Value = 42.5.ToString() // double → string -}); -``` - -**v2 client code:** -```csharp -await client.WriteAsync(new WriteRequest -{ - SessionId = sid, - Tag = "Motor.Speed", - Value = new TypedValue { DoubleValue = 42.5 } // native type -}); -``` - -### 4.3 Subscription Stream - -No RPC signature changes. The streamed `VtqMessage` items now use the updated format. Client `onUpdate` callbacks receive typed values and structured quality. - -### 4.4 Error Conditions with New Quality Codes - -The server now returns specific quality codes instead of generic `"Bad"`: - -| Scenario | v1 Quality | v2 Quality | -|----------|-----------|-----------| -| Tag not found | `"Bad"` | `BadConfigurationError` (`0x80040000`) | -| Tag read exception / comms loss | `"Bad"` | `BadCommunicationFailure` (`0x80050000`) | -| Write to read-only tag | `success=false` | WriteResult.success=false, message indicates read-only | -| Type mismatch on write | `success=false` | WriteResult.success=false, message indicates type mismatch | -| Simulated sensor failure | `"Bad"` | `BadSensorFailure` (`0x806D0000`) | -| Simulated device failure | `"Bad"` | `BadDeviceFailure` (`0x806B0000`) | -| Stale value (fault injection) | `"Uncertain"` | `UncertainLastUsableValue` (`0x40900000`) | -| Block off-scan / disabled | `"Bad"` | `BadOutOfService` (`0x808F0000`) | -| Local override active | `"Good"` | `GoodLocalOverride` (`0x00D80000`) | -| Initializing / waiting for first value | `"Bad"` | `BadWaitingForInitialData` (`0x80320000`) | - ---- - -## 5. Migration Guide - -### 5.1 Strategy - -**Clean break** — all clients and servers are updated simultaneously in a single coordinated release. No backward compatibility layer, no version negotiation, no dual-format support. - -This is appropriate because: -- The LmxProxy is an internal protocol between ScadaLink components, not a public API -- The number of clients is small and controlled -- Maintaining dual formats adds complexity with no long-term benefit - -### 5.2 Server-Side Changes - -**Files to update:** - -| File | Changes | -|------|---------| -| `scada.proto` | Replace with v2 proto (Section 2 of this document) | -| `ScadaServiceImpl.cs` | Update all RPC handlers to construct `TypedValue` and `QualityCode` instead of strings | -| `SessionManager.cs` | No changes (session model unchanged) | -| `TagMapper.cs` | Update to return `TypedValue` from tag reads instead of string conversion | - -**Server implementation notes:** -- When reading a tag, construct `TypedValue` by setting the appropriate `oneof` field based on the tag's data type. Do not call `.ToString()`. -- When a tag read fails, return `QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }` (or a more specific code) instead of the string `"Bad"`. -- When handling writes, extract the value from the `TypedValue` oneof and apply it to the tag actor. If the `oneof` case doesn't match the tag's expected data type, return `WriteResult` with `success=false` and message indicating type mismatch. -- For `WriteBatchAndWait` flag comparison, implement `TypedValueEquals()` per the comparison rules in Section 3.3. - -### 5.3 Client-Side Changes - -**Files to update:** - -| File | Changes | -|------|---------| -| `ILmxProxyClient.cs` | Interface unchanged (same method signatures, updated message types come from proto regeneration) | -| `RealLmxProxyClient.cs` | Update value construction in write methods; update value extraction in read callbacks | -| `LmxProxyDataConnection.cs` | Update DCL adapter to map between DCL's internal value model and `TypedValue`/`QualityCode` | -| `LmxProxyClientFactory.cs` | No changes | - -**Client implementation notes:** -- Replace all `double.Parse(vtq.Value)` / `bool.Parse(vtq.Value)` calls with direct typed access (e.g., `vtq.Value.DoubleValue`). -- Replace all `vtq.Quality.Equals("Good", ...)` string comparisons with numeric status code checks or the `IsGood()`/`IsBad()` helpers. -- Replace all `.ToString()` value serialization in write paths with `TypedValue` construction. -- The `onUpdate` callback signature in `SubscribeAsync` doesn't change at the interface level, but the `VtqMessage` it receives now contains `TypedValue` and `QualityCode`. - -### 5.4 Migration Checklist - -``` -[ ] Generate updated C# classes from v2 proto file -[ ] Update server: ScadaServiceImpl read handlers → TypedValue + QualityCode -[ ] Update server: ScadaServiceImpl write handlers → accept TypedValue -[ ] Update server: WriteBatchAndWait flag comparison → TypedValueEquals() -[ ] Update server: Error paths → specific QualityCode status codes -[ ] Update client: RealLmxProxyClient read paths → typed value extraction -[ ] Update client: RealLmxProxyClient write paths → TypedValue construction -[ ] Update client: Quality checks → numeric status code comparison -[ ] Update client: LmxProxyDataConnection DCL adapter → map TypedValue ↔ DCL values -[ ] Update all unit tests for new message shapes -[ ] Integration test: client ↔ server round-trip with all data types -[ ] Integration test: WriteBatchAndWait with typed flag comparison -[ ] Integration test: Subscription stream delivers typed VTQ messages -[ ] Integration test: Error paths return correct QualityCode sub-codes -[ ] Remove all string-based value parsing/serialization code -[ ] Remove all string-based quality comparison code -``` - ---- - -## 6. Test Scenarios for v2 Validation - -These scenarios validate that the v2 protocol behaves correctly across all data types and quality codes. - -### 6.1 Round-Trip Type Fidelity - -For each supported data type, write a value via `Write`, read it back via `Read`, and verify the `TypedValue` oneof case and value match exactly: - -| Data Type | Test Value | TypedValue Field | Verify | -|-----------|-----------|-----------------|--------| -| `bool` | `true` | `bool_value` | `== true` | -| `int32` | `2147483647` | `int32_value` | `== int.MaxValue` | -| `int64` | `9223372036854775807` | `int64_value` | `== long.MaxValue` | -| `float` | `3.14159f` | `float_value` | `== 3.14159f` (exact bits) | -| `double` | `2.718281828459045` | `double_value` | `== 2.718281828459045` (exact bits) | -| `string` | `"Hello World"` | `string_value` | `== "Hello World"` | -| `bytes` | `[0x00, 0xFF, 0x42]` | `bytes_value` | byte-for-byte match | -| `DateTime` | `638789000000000000L` | `datetime_value` | `== 638789000000000000L` | -| `float[]` | `[1.0f, 2.0f, 3.0f]` | `array_value.float_values` | element-wise match | -| `int32[]` | `[10, 20, 30]` | `array_value.int32_values` | element-wise match | -| null | (unset) | no oneof case | `Value case == None` | - -### 6.2 Quality Code Propagation - -| Scenario | Trigger | Expected QualityCode | -|----------|---------|---------------------| -| Normal read | Read a healthy tag | `{ 0x00000000, "Good" }` | -| Local override | Script sets `GoodLocalOverride` | `{ 0x00D80000, "GoodLocalOverride" }` | -| Fault injection: sensor failure | Script sets `BadSensorFailure` | `{ 0x806D0000, "BadSensorFailure" }` | -| Fault injection: device failure | Script sets `BadDeviceFailure` | `{ 0x806B0000, "BadDeviceFailure" }` | -| Fault injection: stale value | Script sets `UncertainLastUsableValue` | `{ 0x40900000, "UncertainLastUsableValue" }` | -| Fault injection: off-scan | Script sets `BadOutOfService` | `{ 0x808F0000, "BadOutOfService" }` | -| Fault injection: comms failure | Script sets `BadCommunicationFailure` | `{ 0x80050000, "BadCommunicationFailure" }` | -| Unknown tag | Read nonexistent tag | `{ 0x80040000, "BadConfigurationError" }` | -| Write to read-only | Write to a read-only tag | WriteResult.success=false, message contains "read-only" | - -### 6.3 WriteBatchAndWait Typed Flag Comparison - -| Flag Type | Written Value | Flag Value | Expected Result | -|-----------|--------------|-----------|-----------------| -| `bool` | `true` | `TypedValue { bool_value = true }` | `flag_reached = true` | -| `bool` | `false` | `TypedValue { bool_value = true }` | `flag_reached = false` (timeout) | -| `double` | `42.5` | `TypedValue { double_value = 42.5 }` | `flag_reached = true` | -| `double` | `42.500001` | `TypedValue { double_value = 42.5 }` | `flag_reached = false` | -| `string` | `"DONE"` | `TypedValue { string_value = "DONE" }` | `flag_reached = true` | -| `string` | `"done"` | `TypedValue { string_value = "DONE" }` | `flag_reached = false` (case-sensitive) | -| `int32` | `1` | `TypedValue { double_value = 1.0 }` | `flag_reached = false` (type mismatch) | - -### 6.4 Subscription Stream - -- Subscribe to tags of mixed data types -- Verify each streamed `VtqMessage` has the correct `oneof` case matching the tag's data type -- Inject a fault mid-stream and verify the quality code changes from `Good` to the injected code -- Cancel the subscription and verify the stream terminates cleanly - ---- - -## 7. Appendix: v1 → v2 Quick Reference - -**Reading a value:** -```csharp -// v1 -string raw = vtq.Value; -if (double.TryParse(raw, out var d)) { /* use d */ } -else if (bool.TryParse(raw, out var b)) { /* use b */ } -else { /* it's a string */ } - -// v2 -switch (vtq.Value.ValueCase) -{ - case TypedValue.ValueOneofCase.DoubleValue: - double d = vtq.Value.DoubleValue; - break; - case TypedValue.ValueOneofCase.BoolValue: - bool b = vtq.Value.BoolValue; - break; - case TypedValue.ValueOneofCase.StringValue: - string s = vtq.Value.StringValue; - break; - case TypedValue.ValueOneofCase.None: - // null value - break; - // ... other cases -} -``` - -**Writing a value:** -```csharp -// v1 -new WriteItem { Tag = "Motor.Speed", Value = 42.5.ToString() } - -// v2 -new WriteItem { Tag = "Motor.Speed", Value = new TypedValue { DoubleValue = 42.5 } } -``` - -**Checking quality:** -```csharp -// v1 -bool isGood = vtq.Quality.Equals("Good", StringComparison.OrdinalIgnoreCase); -bool isBad = vtq.Quality.Equals("Bad", StringComparison.OrdinalIgnoreCase); - -// v2 -bool isGood = (vtq.Quality.StatusCode & 0xC0000000) == 0x00000000; -bool isBad = (vtq.Quality.StatusCode & 0xC0000000) == 0x80000000; -// or use helper: -bool isGood = QualityHelper.IsGood(vtq.Quality.StatusCode); -``` - -**Constructing quality (server-side):** -```csharp -// v1 -vtq.Quality = "Good"; - -// v2 -vtq.Quality = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; -// or for errors: -vtq.Quality = new QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }; -vtq.Quality = new QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }; -vtq.Quality = new QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" }; -``` - ---- - -*Document version: 1.0 — All decisions resolved. Complete proto, migration guide, and test scenarios.* diff --git a/deprecated/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md b/deprecated/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md deleted file mode 100644 index 3a82f6b..0000000 --- a/deprecated/lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md +++ /dev/null @@ -1,210 +0,0 @@ -# LmxProxy v2 Rebuild — Design Document - -**Date**: 2026-03-21 -**Status**: Approved -**Scope**: Complete rebuild of LmxProxy Host and Client with v2 protocol - -## 1. Overview - -Rebuild the LmxProxy gRPC proxy service from scratch, implementing the v2 protocol (TypedValue + QualityCode) as defined in `docs/lmxproxy_updates.md`. The existing code in `src/` is retained as reference only. No backward compatibility with v1. - -## 2. Key Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| gRPC server for Host | Grpc.Core (C-core) | Only option for .NET Framework 4.8 server-side | -| Service hosting | Topshelf | Proven, already deployed, simple install/uninstall | -| Protocol version | v2 only, clean break | Small controlled client count, no value in v1 compat | -| Shared code between projects | None — fully independent | Different .NET runtimes (.NET Fx 4.8 vs .NET 10), wire compat is the contract | -| Client retry library | Polly v8+ | Building fresh on .NET 10, modern API | -| Testing strategy | Unit tests during implementation, integration tests after Client functional | Phased approach, real hardware validation on windev | - -## 3. Architecture - -### 3.1 Host (.NET Framework 4.8, x86) - -``` -Program.cs (Topshelf entry point) - └── LmxProxyService (lifecycle manager) - ├── Configuration (appsettings.json binding + validation) - ├── MxAccessClient (COM interop, STA dispatch thread) - │ ├── Connection state machine - │ ├── Read/Write with semaphore concurrency - │ ├── Subscription storage for reconnect replay - │ └── Auto-reconnect loop (5s interval) - ├── SessionManager (ConcurrentDictionary, 5-min inactivity scavenging) - ├── SubscriptionManager (per-client channels, shared MxAccess subscriptions) - ├── ApiKeyService (JSON file, FileSystemWatcher hot-reload) - ├── ScadaGrpcService (proto-generated, all 10 RPCs) - │ └── ApiKeyInterceptor (x-api-key header enforcement) - ├── PerformanceMetrics (per-op tracking, p95, 60s log) - ├── HealthCheckService (basic + detailed with test tag) - └── StatusWebServer (HTML dashboard, JSON status, health endpoint) -``` - -### 3.2 Client (.NET 10, AnyCPU) - -``` -ILmxProxyClient (public interface) - └── LmxProxyClient (partial class) - ├── Connection (GrpcChannel, protobuf-net.Grpc, 30s keep-alive) - ├── Read/Write/Subscribe operations - ├── CodeFirstSubscription (IAsyncEnumerable streaming) - ├── ClientMetrics (p95/p99, 1000-sample buffer) - └── Disposal (session disconnect, channel cleanup) - -LmxProxyClientBuilder (fluent builder, Polly v8 resilience pipeline) -ILmxProxyClientFactory + LmxProxyClientFactory (config-based creation) -ServiceCollectionExtensions (DI registrations) -StreamingExtensions (batched reads/writes, parallel processing) - -Domain/ - ├── ScadaContracts.cs (IScadaService + all DataContract messages) - ├── Quality.cs, QualityExtensions.cs - ├── Vtq.cs - └── ConnectionState.cs -``` - -### 3.3 Wire Compatibility - -The `.proto` file is the single source of truth for the wire format. Host generates server stubs from it. Client implements code-first contracts (`[DataContract]`/`[ServiceContract]`) that mirror the proto exactly — same field numbers, names, nesting, and streaming shapes. Cross-stack serialization tests verify compatibility. - -## 4. Protocol (v2) - -### 4.1 TypedValue System - -Protobuf `oneof` carrying native types: - -| Case | Proto Type | .NET Type | -|------|-----------|-----------| -| bool_value | bool | bool | -| int32_value | int32 | int | -| int64_value | int64 | long | -| float_value | float | float | -| double_value | double | double | -| string_value | string | string | -| bytes_value | bytes | byte[] | -| datetime_value | int64 (UTC Ticks) | DateTime | -| array_value | ArrayValue | typed arrays | - -Unset `oneof` = null. No string serialization heuristics. - -### 4.2 COM Variant Coercion Table - -| COM Variant Type | TypedValue Case | Notes | -|-----------------|-----------------|-------| -| VT_BOOL | bool_value | | -| VT_I2 (short) | int32_value | Widened | -| VT_I4 (int) | int32_value | | -| VT_I8 (long) | int64_value | | -| VT_UI2 (ushort) | int32_value | Widened | -| VT_UI4 (uint) | int64_value | Widened to avoid sign issues | -| VT_UI8 (ulong) | int64_value | Truncation risk logged if > long.MaxValue | -| VT_R4 (float) | float_value | | -| VT_R8 (double) | double_value | | -| VT_BSTR (string) | string_value | | -| VT_DATE (DateTime) | datetime_value | Converted to UTC Ticks | -| VT_DECIMAL | double_value | Precision loss logged | -| VT_CY (Currency) | double_value | | -| VT_NULL, VT_EMPTY, DBNull | unset oneof | Represents null | -| VT_ARRAY | array_value | Element type determines ArrayValue field | -| VT_UNKNOWN | string_value | ToString() fallback, logged as warning | - -### 4.3 QualityCode System - -`status_code` (uint32, OPC UA-compatible) is canonical. `symbolic_name` is derived from a lookup table, never set independently. - -Category derived from high bits: -- `0x00xxxxxx` = Good -- `0x40xxxxxx` = Uncertain -- `0x80xxxxxx` = Bad - -Domain `Quality` enum uses byte values for the low-order byte, with extension methods `IsGood()`, `IsBad()`, `IsUncertain()`. - -### 4.4 Error Model - -| Error Type | Mechanism | Examples | -|-----------|-----------|----------| -| Infrastructure | gRPC StatusCode | Unauthenticated (bad API key), PermissionDenied (ReadOnly write), InvalidArgument (bad session), Unavailable (MxAccess down) | -| Business outcome | Payload `success`/`message` fields | Tag read failure, write type mismatch, batch partial failure, WriteBatchAndWait flag timeout | -| Subscription | gRPC StatusCode on stream | Unauthenticated (invalid session), Internal (unexpected error) | - -## 5. COM Threading Model - -MxAccess is an STA COM component. All COM operations execute on a **dedicated STA thread** with a `BlockingCollection` dispatch queue: - -- `MxAccessClient` creates a single STA thread at construction -- All COM calls (connect, read, write, subscribe, disconnect) are dispatched to this thread via the queue -- Callers await a `TaskCompletionSource` that the STA thread completes after the COM call -- The STA thread runs a message pump loop (`Application.Run` or manual `MSG` pump) -- On disposal, a sentinel is enqueued and the thread joins with a 10-second timeout - -This replaces the fragile `Task.Run` + `SemaphoreSlim` pattern in the reference code. - -## 6. Session Lifecycle - -- Sessions created on `Connect` with GUID "N" format (32-char hex) -- Tracked in `ConcurrentDictionary` -- **Inactivity scavenging**: sessions not accessed for 5 minutes are automatically terminated. Client keep-alive pings (30s) keep legitimate sessions alive. -- On termination: subscriptions cleaned up, session removed from dictionary -- All sessions lost on service restart (in-memory only) - -## 7. Subscription Semantics - -- **Shared MxAccess subscriptions**: first client to subscribe creates the underlying MxAccess subscription. Last to unsubscribe disposes it. Ref-counted. -- **Sampling rate**: when multiple clients subscribe to the same tag with different `sampling_ms`, the fastest (lowest non-zero) rate is used for the MxAccess subscription. All clients receive updates at this rate. -- **Per-client channels**: each client gets an independent `BoundedChannel` (capacity 1000, DropOldest). One slow consumer's drops do not affect other clients. -- **MxAccess disconnect**: all subscribed clients receive a bad-quality notification for all their subscribed tags. -- **Session termination**: all subscriptions for that session are cleaned up. - -## 8. Authentication - -- `x-api-key` gRPC metadata header is the authoritative authentication mechanism -- `ConnectRequest.api_key` is accepted but the interceptor is the enforcement point -- API keys loaded from JSON file with FileSystemWatcher hot-reload (1-second debounce) -- Auto-generates default file with two random keys (ReadOnly + ReadWrite) if missing -- Write-protected RPCs: Write, WriteBatch, WriteBatchAndWait - -## 9. Phasing - -| Phase | Scope | Depends On | -|-------|-------|------------| -| 1 | Protocol & Domain Types | — | -| 2 | Host Core (MxAccessClient, SessionManager, SubscriptionManager) | Phase 1 | -| 3 | Host gRPC Server, Security, Configuration, Service Hosting | Phase 2 | -| 4 | Host Health, Metrics, Status Server | Phase 3 | -| 5 | Client Core | Phase 1 | -| 6 | Client Extras (Builder, Factory, DI, Streaming) | Phase 5 | -| 7 | Integration Tests & Deployment | Phases 4 + 6 | - -Phases 2-4 (Host) and 5-6 (Client) can proceed in parallel after Phase 1. - -## 10. Guardrails - -1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts. -2. **No v1 code in the new build** — reference only. Do not copy-paste and modify; write fresh. -3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic. -4. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the dispatch queue. -5. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set. -6. **Unit tests before integration** — every phase includes unit tests. Integration tests are Phase 7 only. -7. **Each phase must compile and pass tests** before the next phase begins. -8. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values. - -## 11. Resolved Conflicts - -| Conflict | Resolution | -|----------|-----------| -| WriteBatchAndWait signature (MxAccessClient vs Protocol) | Follow Protocol spec: write items, poll flagTag for flagValue. IScadaClient interface matches protocol semantics. | -| Builder default port 5050 vs Host 50051 | Standardize builder default to 50051 | -| Auth in metadata vs payload | x-api-key header is authoritative; ConnectRequest.api_key accepted but interceptor enforces | - -## 12. Reference Code - -The existing code remains in `src/` as `src-reference/` for consultation: -- `src-reference/ZB.MOM.WW.LmxProxy.Host/` — v1 Host implementation -- `src-reference/ZB.MOM.WW.LmxProxy.Client/` — v1 Client implementation - -Key reference files for COM interop patterns: -- `Implementation/MxAccessClient.Connection.cs` — COM object lifecycle -- `Implementation/MxAccessClient.EventHandlers.cs` — MxAccess callbacks -- `Implementation/MxAccessClient.Subscription.cs` — Advise/Unadvise patterns diff --git a/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md b/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md deleted file mode 100644 index 2de44b1..0000000 --- a/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md +++ /dev/null @@ -1,673 +0,0 @@ -# Gap 1 & Gap 2: Active Health Probing + Subscription Handle Cleanup - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. - -**Goal:** Fix two reconnect-related gaps: (1) the monitor loop cannot detect a silently-dead MxAccess connection, and (2) SubscriptionManager holds stale IAsyncDisposable handles after reconnect. - -**Architecture:** Add a domain-level connection probe to `MxAccessClient` that classifies results as Healthy/TransportFailure/DataDegraded. The monitor loop uses this to decide reconnect vs degrade-and-backoff. Separately, remove `SubscriptionManager._mxAccessHandles` entirely and switch to address-based unsubscribe through `IScadaClient`, making `MxAccessClient` the sole owner of COM subscription lifecycle. - -**Tech Stack:** .NET Framework 4.8, C#, MxAccess COM interop, Serilog - ---- - -## Task 0: Add `ProbeResult` domain type - -**Files:** -- Create: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs` - -**Step 1: Create the ProbeResult type** - -```csharp -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - public enum ProbeStatus - { - Healthy, - TransportFailure, - DataDegraded - } - - public sealed class ProbeResult - { - public ProbeStatus Status { get; } - public Quality? Quality { get; } - public DateTime? Timestamp { get; } - public string? Message { get; } - public Exception? Exception { get; } - - private ProbeResult(ProbeStatus status, Quality? quality, DateTime? timestamp, - string? message, Exception? exception) - { - Status = status; - Quality = quality; - Timestamp = timestamp; - Message = message; - Exception = exception; - } - - public static ProbeResult Healthy(Quality quality, DateTime timestamp) - => new ProbeResult(ProbeStatus.Healthy, quality, timestamp, null, null); - - public static ProbeResult Degraded(Quality quality, DateTime timestamp, string message) - => new ProbeResult(ProbeStatus.DataDegraded, quality, timestamp, message, null); - - public static ProbeResult TransportFailed(string message, Exception? ex = null) - => new ProbeResult(ProbeStatus.TransportFailure, null, null, message, ex); - } -} -``` - -**Step 2: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs -git commit -m "feat: add ProbeResult domain type for connection health classification" -``` - ---- - -## Task 1: Add `ProbeConnectionAsync` to `MxAccessClient` - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs` — add `ProbeConnectionAsync` to interface -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs` — implement probe method - -**Step 1: Add to IScadaClient interface** - -In `IScadaClient.cs`, add after the `DisconnectAsync` method: - -```csharp -/// -/// Probes connection health by reading a test tag. -/// Returns a classified result: Healthy, TransportFailure, or DataDegraded. -/// -Task ProbeConnectionAsync(string testTagAddress, int timeoutMs, CancellationToken ct = default); -``` - -**Step 2: Implement in MxAccessClient.Connection.cs** - -Add before `MonitorConnectionAsync`: - -```csharp -/// -/// Probes the connection by reading a test tag with a timeout. -/// Classifies the result as transport failure vs data degraded. -/// -public async Task ProbeConnectionAsync(string testTagAddress, int timeoutMs, - CancellationToken ct = default) -{ - if (!IsConnected) - return ProbeResult.TransportFailed("Not connected"); - - try - { - using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct)) - { - cts.CancelAfter(timeoutMs); - - Vtq vtq; - try - { - vtq = await ReadAsync(testTagAddress, cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // Our timeout fired, not the caller's — treat as transport failure - return ProbeResult.TransportFailed("Probe read timed out after " + timeoutMs + "ms"); - } - - if (vtq.Quality == Domain.Quality.Bad_NotConnected || - vtq.Quality == Domain.Quality.Bad_CommFailure) - { - return ProbeResult.TransportFailed("Probe returned " + vtq.Quality); - } - - if (!vtq.Quality.IsGood()) - { - return ProbeResult.Degraded(vtq.Quality, vtq.Timestamp, - "Probe quality: " + vtq.Quality); - } - - if (DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)) - { - return ProbeResult.Degraded(vtq.Quality, vtq.Timestamp, - "Probe data stale (>" + 5 + "min)"); - } - - return ProbeResult.Healthy(vtq.Quality, vtq.Timestamp); - } - } - catch (System.Runtime.InteropServices.COMException ex) - { - return ProbeResult.TransportFailed("COM exception: " + ex.Message, ex); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("Not connected")) - { - return ProbeResult.TransportFailed(ex.Message, ex); - } - catch (Exception ex) - { - return ProbeResult.TransportFailed("Probe failed: " + ex.Message, ex); - } -} -``` - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs -git add src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs -git commit -m "feat: add ProbeConnectionAsync to MxAccessClient for active health probing" -``` - ---- - -## Task 2: Add health check configuration - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs` — add HealthCheckConfiguration class and property - -**Step 1: Add HealthCheckConfiguration** - -Add a new class in the Configuration namespace (can be in the same file or a new file — keep it simple, same file): - -```csharp -/// Health check / probe configuration. -public class HealthCheckConfiguration -{ - /// Tag address to probe for connection liveness. Default: TestChildObject.TestBool. - public string TestTagAddress { get; set; } = "TestChildObject.TestBool"; - - /// Probe timeout in milliseconds. Default: 5000. - public int ProbeTimeoutMs { get; set; } = 5000; - - /// Consecutive transport failures before forced reconnect. Default: 3. - public int MaxConsecutiveTransportFailures { get; set; } = 3; - - /// Probe interval while in degraded state (ms). Default: 30000 (30s). - public int DegradedProbeIntervalMs { get; set; } = 30000; -} -``` - -Add to `LmxProxyConfiguration`: - -```csharp -/// Health check / active probe settings. -public HealthCheckConfiguration HealthCheck { get; set; } = new HealthCheckConfiguration(); -``` - -**Step 2: Add to appsettings.json** - -In the existing `appsettings.json`, add the `HealthCheck` section: - -```json -"HealthCheck": { - "TestTagAddress": "TestChildObject.TestBool", - "ProbeTimeoutMs": 5000, - "MaxConsecutiveTransportFailures": 3, - "DegradedProbeIntervalMs": 30000 -} -``` - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs -git add src/ZB.MOM.WW.LmxProxy.Host/appsettings.json -git commit -m "feat: add HealthCheck configuration section for active connection probing" -``` - ---- - -## Task 3: Rewrite `MonitorConnectionAsync` with active probing - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` — add probe state fields -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs` — rewrite monitor loop - -The monitor needs configuration passed in. The simplest approach: add constructor parameters for the probe settings alongside the existing ones. - -**Step 1: Add probe fields to MxAccessClient.cs** - -Add fields after the existing reconnect fields (around line 42): - -```csharp -// Probe configuration -private readonly string? _probeTestTagAddress; -private readonly int _probeTimeoutMs; -private readonly int _maxConsecutiveTransportFailures; -private readonly int _degradedProbeIntervalMs; - -// Probe state -private int _consecutiveTransportFailures; -private bool _isDegraded; -``` - -Add constructor parameters and assignments. After the existing `_galaxyName = galaxyName;` line: - -```csharp -public MxAccessClient( - int maxConcurrentOperations = 10, - int readTimeoutSeconds = 5, - int writeTimeoutSeconds = 5, - int monitorIntervalSeconds = 5, - bool autoReconnect = true, - string? nodeName = null, - string? galaxyName = null, - string? probeTestTagAddress = null, - int probeTimeoutMs = 5000, - int maxConsecutiveTransportFailures = 3, - int degradedProbeIntervalMs = 30000) -``` - -And in the body: - -```csharp -_probeTestTagAddress = probeTestTagAddress; -_probeTimeoutMs = probeTimeoutMs; -_maxConsecutiveTransportFailures = maxConsecutiveTransportFailures; -_degradedProbeIntervalMs = degradedProbeIntervalMs; -``` - -**Step 2: Rewrite MonitorConnectionAsync in MxAccessClient.Connection.cs** - -Replace the existing `MonitorConnectionAsync` (lines 177-213) with: - -```csharp -/// -/// Auto-reconnect monitor loop with active health probing. -/// - If IsConnected is false: immediate reconnect (existing behavior). -/// - If IsConnected is true and probe configured: read test tag each interval. -/// - TransportFailure for N consecutive probes → forced disconnect + reconnect. -/// - DataDegraded → stay connected, back off probe interval, report degraded. -/// - Healthy → reset counters and resume normal interval. -/// -private async Task MonitorConnectionAsync(CancellationToken ct) -{ - Log.Information("Connection monitor loop started (interval={IntervalMs}ms, probe={ProbeEnabled})", - _monitorIntervalMs, _probeTestTagAddress != null); - - while (!ct.IsCancellationRequested) - { - var interval = _isDegraded ? _degradedProbeIntervalMs : _monitorIntervalMs; - - try - { - await Task.Delay(interval, ct); - } - catch (OperationCanceledException) - { - break; - } - - // ── Case 1: Already disconnected ── - if (!IsConnected) - { - _isDegraded = false; - _consecutiveTransportFailures = 0; - await AttemptReconnectAsync(ct); - continue; - } - - // ── Case 2: Connected, no probe configured — legacy behavior ── - if (_probeTestTagAddress == null) - continue; - - // ── Case 3: Connected, probe configured — active health check ── - var probe = await ProbeConnectionAsync(_probeTestTagAddress, _probeTimeoutMs, ct); - - switch (probe.Status) - { - case ProbeStatus.Healthy: - if (_isDegraded) - { - Log.Information("Probe healthy — exiting degraded mode"); - _isDegraded = false; - } - _consecutiveTransportFailures = 0; - break; - - case ProbeStatus.DataDegraded: - _consecutiveTransportFailures = 0; - if (!_isDegraded) - { - Log.Warning("Probe degraded: {Message} — entering degraded mode (probe interval {IntervalMs}ms)", - probe.Message, _degradedProbeIntervalMs); - _isDegraded = true; - } - break; - - case ProbeStatus.TransportFailure: - _isDegraded = false; - _consecutiveTransportFailures++; - Log.Warning("Probe transport failure ({Count}/{Max}): {Message}", - _consecutiveTransportFailures, _maxConsecutiveTransportFailures, probe.Message); - - if (_consecutiveTransportFailures >= _maxConsecutiveTransportFailures) - { - Log.Warning("Max consecutive transport failures reached — forcing reconnect"); - _consecutiveTransportFailures = 0; - - try - { - await DisconnectAsync(ct); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during forced disconnect before reconnect"); - // DisconnectAsync already calls CleanupComObjectsAsync on error path - } - - await AttemptReconnectAsync(ct); - } - break; - } - } - - Log.Information("Connection monitor loop exited"); -} - -private async Task AttemptReconnectAsync(CancellationToken ct) -{ - Log.Information("Attempting reconnect..."); - SetState(ConnectionState.Reconnecting); - - try - { - await ConnectAsync(ct); - Log.Information("Reconnected to MxAccess successfully"); - } - catch (OperationCanceledException) - { - // Let the outer loop handle cancellation - } - catch (Exception ex) - { - Log.Warning(ex, "Reconnect attempt failed, will retry at next interval"); - } -} -``` - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs -git add src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs -git commit -m "feat: rewrite monitor loop with active probing, transport vs degraded classification" -``` - ---- - -## Task 4: Wire probe config through `LmxProxyService.Start()` - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` — pass HealthCheck config to MxAccessClient constructor - -**Step 1: Update MxAccessClient construction** - -In `LmxProxyService.Start()`, update the MxAccessClient creation (around line 62) to pass the new parameters: - -```csharp -_mxAccessClient = new MxAccessClient( - maxConcurrentOperations: _config.Connection.MaxConcurrentOperations, - readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds, - writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds, - monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds, - autoReconnect: _config.Connection.AutoReconnect, - nodeName: _config.Connection.NodeName, - galaxyName: _config.Connection.GalaxyName, - probeTestTagAddress: _config.HealthCheck.TestTagAddress, - probeTimeoutMs: _config.HealthCheck.ProbeTimeoutMs, - maxConsecutiveTransportFailures: _config.HealthCheck.MaxConsecutiveTransportFailures, - degradedProbeIntervalMs: _config.HealthCheck.DegradedProbeIntervalMs); -``` - -**Step 2: Update DetailedHealthCheckService to use shared probe** - -In `LmxProxyService.Start()`, update the DetailedHealthCheckService construction (around line 114) to pass the test tag address from config: - -```csharp -_detailedHealthCheckService = new DetailedHealthCheckService( - _mxAccessClient, _config.HealthCheck.TestTagAddress); -``` - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs -git commit -m "feat: wire HealthCheck config to MxAccessClient and DetailedHealthCheckService" -``` - ---- - -## Task 5: Add `UnsubscribeByAddressAsync` to `IScadaClient` and `MxAccessClient` - -This is the foundation for removing handle-based unsubscribe from SubscriptionManager. - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs` — add `UnsubscribeByAddressAsync` -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs` — implement, change `UnsubscribeAsync` visibility - -**Step 1: Add to IScadaClient** - -After `SubscribeAsync`: - -```csharp -/// -/// Unsubscribes specific tag addresses. Removes from stored subscriptions -/// and COM state. Safe to call after reconnect — uses current handle mappings. -/// -Task UnsubscribeByAddressAsync(IEnumerable addresses); -``` - -**Step 2: Implement in MxAccessClient.Subscription.cs** - -The existing `UnsubscribeAsync` (line 53) already does exactly this — it's just `internal`. Rename it or add a public wrapper: - -```csharp -/// -/// Unsubscribes specific addresses by address name. -/// Removes from both COM state and stored subscriptions (no reconnect replay). -/// -public async Task UnsubscribeByAddressAsync(IEnumerable addresses) -{ - await UnsubscribeAsync(addresses); -} -``` - -This keeps the existing `internal UnsubscribeAsync` unchanged (it's still used by `SubscriptionHandle.DisposeAsync`). - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs -git add src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs -git commit -m "feat: add UnsubscribeByAddressAsync to IScadaClient for address-based unsubscribe" -``` - ---- - -## Task 6: Remove `_mxAccessHandles` from `SubscriptionManager` - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` - -**Step 1: Remove `_mxAccessHandles` field** - -Delete line 34-35: - -```csharp -// REMOVE: -private readonly ConcurrentDictionary _mxAccessHandles - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); -``` - -**Step 2: Rewrite `CreateMxAccessSubscriptionsAsync`** - -The method no longer stores handles. It just calls `SubscribeAsync` to create the COM subscriptions. `MxAccessClient` stores them in `_storedSubscriptions` internally. - -```csharp -private async Task CreateMxAccessSubscriptionsAsync(List addresses) -{ - try - { - await _scadaClient.SubscribeAsync( - addresses, - (address, vtq) => OnTagValueChanged(address, vtq)); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to create MxAccess subscriptions for {Count} tags", addresses.Count); - } -} -``` - -**Step 3: Rewrite unsubscribe logic in `UnsubscribeClient`** - -Replace the handle disposal section (lines 198-212) with address-based unsubscribe: - -```csharp -// Unsubscribe tags with no remaining clients via address-based API -if (tagsToDispose.Count > 0) -{ - try - { - _scadaClient.UnsubscribeByAddressAsync(tagsToDispose).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error unsubscribing {Count} tags from MxAccess", tagsToDispose.Count); - } -} -``` - -**Step 4: Verify build** - -```bash -dotnet build src/ZB.MOM.WW.LmxProxy.Host -``` - -Expected: Build succeeds. No references to `_mxAccessHandles` remain. - -**Step 5: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs -git commit -m "fix: remove _mxAccessHandles from SubscriptionManager, use address-based unsubscribe" -``` - ---- - -## Task 7: Wire `ConnectionStateChanged` for reconnect notification in `SubscriptionManager` - -After reconnect, `RecreateStoredSubscriptionsAsync` recreates COM subscriptions, and `SubscriptionManager` continues to receive `OnTagValueChanged` callbacks because the callback references are preserved in `_storedSubscriptions`. However, we should notify subscribed clients that quality has been restored. - -**Files:** -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` — add `NotifyReconnection` method -- Modify: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` — wire Connected state to SubscriptionManager - -**Step 1: Add `NotifyReconnection` to SubscriptionManager** - -```csharp -/// -/// Logs reconnection for observability. Data flow resumes automatically -/// via MxAccessClient.RecreateStoredSubscriptionsAsync callbacks. -/// -public void NotifyReconnection() -{ - Log.Information("MxAccess reconnected — subscriptions recreated, " + - "data flow will resume via OnDataChange callbacks " + - "({ClientCount} clients, {TagCount} tags)", - _clientSubscriptions.Count, _tagSubscriptions.Count); -} -``` - -**Step 2: Wire in LmxProxyService.Start()** - -Extend the existing `ConnectionStateChanged` handler (around line 97): - -```csharp -_mxAccessClient.ConnectionStateChanged += (sender, e) => -{ - if (e.CurrentState == Domain.ConnectionState.Disconnected || - e.CurrentState == Domain.ConnectionState.Error) - { - _subscriptionManager.NotifyDisconnection(); - } - else if (e.CurrentState == Domain.ConnectionState.Connected && - e.PreviousState == Domain.ConnectionState.Reconnecting) - { - _subscriptionManager.NotifyReconnection(); - } -}; -``` - -**Step 3: Commit** - -```bash -git add src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs -git add src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs -git commit -m "feat: wire reconnection notification to SubscriptionManager for observability" -``` - ---- - -## Task 8: Build, deploy to windev, test - -**Files:** -- No code changes — build and deployment task. - -**Step 1: Build the solution** - -```bash -dotnet build ZB.MOM.WW.LmxProxy.slnx -``` - -Expected: Clean build, no errors. - -**Step 2: Deploy to windev** - -Follow existing deployment procedure per `docker/README.md` or manual copy to windev. - -**Step 3: Manual test — Gap 1 (active probing)** - -1. Start the v2 service on windev. Verify logs show: `Connection monitor loop started (interval=5000ms, probe=True)`. -2. Verify probe runs: logs should show no warnings while platform is healthy. -3. Kill aaBootstrap on windev. Within 15-20s (3 probe failures at 5s intervals), logs should show: - - `Probe transport failure (1/3): Probe returned Bad_CommFailure` (or similar) - - `Probe transport failure (2/3): ...` - - `Probe transport failure (3/3): ...` - - `Max consecutive transport failures reached — forcing reconnect` - - `Attempting reconnect...` -4. After platform restart (but objects still stopped): Logs should show `Probe degraded` and `entering degraded mode`, then probe backs off to 30s interval. No reconnect churn. -5. After objects restart via SMC: Logs should show `Probe healthy — exiting degraded mode`. - -**Step 4: Manual test — Gap 2 (subscription cleanup)** - -1. Connect a gRPC client, subscribe to tags. -2. Kill aaBootstrap → client receives `Bad_NotConnected` quality. -3. Restart platform + objects. Verify client starts receiving Good quality updates again (via `RecreateStoredSubscriptionsAsync`). -4. Disconnect the client. Verify logs show `Unsubscribed from N tags` (address-based) with no handle disposal errors. - ---- - -## Design Rationale - -### Why two failure modes in the probe? - -| Failure Mode | Cause | Correct Response | -|---|---|---| -| **Transport failure** | COM object dead, platform process crashed, MxAccess unreachable | Force disconnect + reconnect | -| **Data degraded** | COM session alive, AVEVA objects stopped, all reads return Bad quality | Stay connected, report degraded, back off probes | - -Reconnecting on DataDegraded would churn COM objects with no benefit — the platform objects are stopped regardless of connection state. Observed: 40+ minutes of Bad quality after aaBootstrap crash until manual SMC restart. - -### Why remove `_mxAccessHandles`? - -1. **Batch handle bug**: `CreateMxAccessSubscriptionsAsync` stored the same `IAsyncDisposable` handle for every address in a batch. Disposing any one address disposed the entire batch, silently removing unrelated subscriptions from `_storedSubscriptions`. -2. **Stale after reconnect**: `RecreateStoredSubscriptionsAsync` recreates COM subscriptions but doesn't produce new `SubscriptionManager` handles. Old handles point to disposed COM state. -3. **Ownership violation**: `MxAccessClient` already owns subscription lifecycle via `_storedSubscriptions` and `_addressToHandle`. Duplicating ownership in `SubscriptionManager._mxAccessHandles` is a leaky abstraction. - -The fix: `SubscriptionManager` owns client routing and ref counts only. `MxAccessClient` owns COM subscription lifecycle. Unsubscribe is by address, not by opaque handle. diff --git a/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md.tasks.json b/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md.tasks.json deleted file mode 100644 index 774e3da..0000000 --- a/deprecated/lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md.tasks.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "planPath": "lmxproxy/docs/plans/2026-03-22-gap1-gap2-reconnect-subscriptions.md", - "tasks": [ - {"id": 0, "subject": "Task 0: Add ProbeResult domain type", "status": "pending"}, - {"id": 1, "subject": "Task 1: Add ProbeConnectionAsync to MxAccessClient", "status": "pending", "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: Add health check configuration", "status": "pending"}, - {"id": 3, "subject": "Task 3: Rewrite MonitorConnectionAsync with active probing", "status": "pending", "blockedBy": [1, 2]}, - {"id": 4, "subject": "Task 4: Wire probe config through LmxProxyService.Start()", "status": "pending", "blockedBy": [2, 3]}, - {"id": 5, "subject": "Task 5: Add UnsubscribeByAddressAsync to IScadaClient", "status": "pending"}, - {"id": 6, "subject": "Task 6: Remove _mxAccessHandles from SubscriptionManager", "status": "pending", "blockedBy": [5]}, - {"id": 7, "subject": "Task 7: Wire ConnectionStateChanged for reconnect notification", "status": "pending", "blockedBy": [6]}, - {"id": 8, "subject": "Task 8: Build, deploy to windev, test", "status": "pending", "blockedBy": [4, 7]} - ], - "lastUpdated": "2026-03-22T00:00:00Z" -} diff --git a/deprecated/lmxproxy/docs/plans/lmxproxy-stale-session-fix.md b/deprecated/lmxproxy/docs/plans/lmxproxy-stale-session-fix.md deleted file mode 100644 index 0d17c7f..0000000 --- a/deprecated/lmxproxy/docs/plans/lmxproxy-stale-session-fix.md +++ /dev/null @@ -1,185 +0,0 @@ -# LmxProxy Stale Session Subscription Leak Fix - -## Problem - -When a gRPC client disconnects abruptly, Grpc.Core (the C-core library used by the .NET Framework 4.8 server) does not reliably fire the `ServerCallContext.CancellationToken`. This means: - -1. The `Subscribe` RPC in `ScadaGrpcService` blocks forever on `reader.WaitToReadAsync(context.CancellationToken)` (line 368) -2. The `finally` block with `_subscriptionManager.UnsubscribeClient(request.SessionId)` never runs -3. The `ct.Register(() => UnsubscribeClient(clientId))` in `SubscriptionManager.SubscribeAsync` also never fires (same token) -4. The old session's subscriptions leak in `SubscriptionManager._clientSubscriptions` and `_tagSubscriptions` - -When the client reconnects with a new session ID, it creates duplicate subscriptions. Tags aren't cleaned up because they still have a ref-count from the leaked old session. Over time, client count grows and tag subscriptions accumulate. - -The `SessionManager` does scavenge inactive sessions after 5 minutes, but it only removes the session from its own dictionary — it doesn't notify `SubscriptionManager` to clean up subscriptions. - -## Fix - -Bridge `SessionManager` scavenging to `SubscriptionManager` cleanup. When a session is scavenged due to inactivity, also call `SubscriptionManager.UnsubscribeClient()`. - -### Step 1: Add cleanup callback to SessionManager - -File: `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` - -Add a callback field and expose it: - -```csharp -// Add after the _inactivityTimeout field (line 22) -private Action? _onSessionScavenged; - -/// -/// Register a callback invoked when a session is scavenged due to inactivity. -/// The callback receives the session ID. -/// -public void OnSessionScavenged(Action callback) -{ - _onSessionScavenged = callback; -} -``` - -Then in `ScavengeInactiveSessions`, invoke the callback for each scavenged session: - -```csharp -// In ScavengeInactiveSessions (line 103-118), change the foreach to: -foreach (var kvp in expired) -{ - if (_sessions.TryRemove(kvp.Key, out _)) - { - Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", - kvp.Key, kvp.Value.LastActivity); - - // Notify subscriber cleanup - try - { - _onSessionScavenged?.Invoke(kvp.Key); - } - catch (Exception ex) - { - Log.Warning(ex, "Error in session scavenge callback for {SessionId}", kvp.Key); - } - } -} -``` - -### Step 2: Wire up the callback in LmxProxyService - -File: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` - -After both `SessionManager` and `SubscriptionManager` are created, register the callback: - -```csharp -// Add after SubscriptionManager creation: -_sessionManager.OnSessionScavenged(sessionId => -{ - Log.Information("Cleaning up subscriptions for scavenged session {SessionId}", sessionId); - _subscriptionManager.UnsubscribeClient(sessionId); -}); -``` - -Find where `_sessionManager` and `_subscriptionManager` are both initialized and add this line right after. - -### Step 3: Also clean up on explicit Disconnect - -This is already handled — `ScadaGrpcService.Disconnect()` (line 86) calls `_subscriptionManager.UnsubscribeClient(request.SessionId)` before terminating the session. No change needed. - -### Step 4: Add proactive stream timeout (belt-and-suspenders) - -The scavenger runs every 60 seconds with a 5-minute timeout. This means a leaked session could take up to 6 minutes to clean up. For faster detection, add a secondary timeout in the Subscribe RPC itself. - -File: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs` - -In the `Subscribe` method, replace the simple `context.CancellationToken` with a combined token that also expires if the session becomes invalid: - -```csharp -// Replace the Subscribe method (lines 353-390) with: -public override async Task Subscribe( - Scada.SubscribeRequest request, - IServerStreamWriter responseStream, - ServerCallContext context) -{ - if (!_sessionManager.ValidateSession(request.SessionId)) - { - throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid session")); - } - - var reader = await _subscriptionManager.SubscribeAsync( - request.SessionId, request.Tags, context.CancellationToken); - - try - { - // Use a combined approach: check both the gRPC cancellation token AND - // periodic session validity. This works around Grpc.Core not reliably - // firing CancellationToken on client disconnect. - while (true) - { - // Wait for data with a timeout so we can periodically check session validity - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - context.CancellationToken, timeoutCts.Token); - - bool hasData; - try - { - hasData = await reader.WaitToReadAsync(linkedCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested - && !context.CancellationToken.IsCancellationRequested) - { - // Timeout expired, not a client disconnect — check if session is still valid - if (!_sessionManager.ValidateSession(request.SessionId)) - { - Log.Information("Subscribe stream ending — session {SessionId} no longer valid", - request.SessionId); - break; - } - continue; // Session still valid, keep waiting - } - - if (!hasData) break; // Channel completed - - while (reader.TryRead(out var item)) - { - var protoVtq = ConvertToProtoVtq(item.address, item.vtq); - await responseStream.WriteAsync(protoVtq); - } - } - } - catch (OperationCanceledException) - { - // Client disconnected -- normal - } - catch (Exception ex) - { - Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId); - throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message)); - } - finally - { - _subscriptionManager.UnsubscribeClient(request.SessionId); - } -} -``` - -This adds a 30-second periodic check: if no data arrives for 30 seconds, it checks whether the session is still valid. If the session was scavenged (client disconnected, 5-min timeout), the stream exits cleanly and runs the `finally` cleanup. - -## Summary of Changes - -| File | Change | -|------|--------| -| `Sessions/SessionManager.cs` | Add `_onSessionScavenged` callback, invoke during `ScavengeInactiveSessions` | -| `LmxProxyService.cs` | Wire `_sessionManager.OnSessionScavenged` to `_subscriptionManager.UnsubscribeClient` | -| `Grpc/Services/ScadaGrpcService.cs` | Add 30-second periodic session validity check in `Subscribe` loop | - -## Testing - -1. Start LmxProxy server -2. Connect a client and subscribe to tags -3. Kill the client process abruptly (not a clean disconnect) -4. Check status page — client count should still show the old session -5. Wait up to 5 minutes — session should be scavenged, subscription count should drop -6. Reconnect client — should get a clean new session, no duplicate subscriptions -7. Verify tag subscription counts match expected (no leaked refs) - -## Optional: Reduce scavenge timeout for faster cleanup - -In `LmxProxyService.cs` where `SessionManager` is constructed, consider reducing `inactivityTimeoutMinutes` from 5 to 2, since the Subscribe RPC now has its own 30-second validity check. The 5-minute timeout was the only cleanup path before; now it's belt-and-suspenders. diff --git a/deprecated/lmxproxy/docs/plans/phase-1-protocol-domain-types.md b/deprecated/lmxproxy/docs/plans/phase-1-protocol-domain-types.md deleted file mode 100644 index e560b8f..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-1-protocol-domain-types.md +++ /dev/null @@ -1,2723 +0,0 @@ -# Phase 1: Protocol & Domain Types — Implementation Plan - -## Prerequisites - -### Rename existing source to reference -```bash -cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy -mv src src-reference -mkdir -p src/ZB.MOM.WW.LmxProxy.Host -mkdir -p src/ZB.MOM.WW.LmxProxy.Client -mkdir -p tests/ZB.MOM.WW.LmxProxy.Host.Tests -mkdir -p tests/ZB.MOM.WW.LmxProxy.Client.Tests -``` - -### Update solution file - -Overwrite `ZB.MOM.WW.LmxProxy.slnx` with: - -```xml - - - - - - - - - - -``` - -## Guardrails - -1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts. -2. **No v1 code in the new build** — `src-reference/` is for consultation only. Do not copy-paste and modify; write fresh. -3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic. -4. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set. -5. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values. -6. **Each phase must compile and pass tests** before the next phase begins. -7. **No references to old namespaces** — `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` must not appear anywhere. - ---- - -## Step 1: Create project files - -### 1.1 Host csproj - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj` - -```xml - - - - net48 - Exe - 9.0 - enable - false - ZB.MOM.WW.LmxProxy.Host - ZB.MOM.WW.LmxProxy.Host - x86 - x86 - true - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MXAccess.dll - true - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - -``` - -**Note**: Grpc.Tools bumped to 2.71.0 for latest protoc codegen. Google.Protobuf bumped to 3.29.3 for latest runtime. Polly stays at 7.x because .NET Framework 4.8 does not support Polly v8's modern pipeline API. Newtonsoft.Json added for API key file serialization (System.Text.Json is not well-supported on net48). - -### 1.2 Client csproj - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj` - -```xml - - - - net10.0 - latest - enable - ZB.MOM.WW.LmxProxy.Client - ZB.MOM.WW.LmxProxy.Client - true - true - gRPC client library for LmxProxy SCADA proxy service - AnyCPU - AnyCPU - - - - - - - - - - - - - - -``` - -### 1.3 Host.Tests csproj - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj` - -```xml - - - - net48 - 9.0 - enable - false - ZB.MOM.WW.LmxProxy.Host.Tests - x86 - x86 - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - -``` - -### 1.4 Client.Tests csproj - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj` - -```xml - - - - net10.0 - latest - enable - false - ZB.MOM.WW.LmxProxy.Client.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - -``` - -**Important note on cross-stack tests**: The Client.Tests project includes the Host's proto file with `GrpcServices="None"` (message types only, no service stubs). This lets us serialize using Google.Protobuf generated types and deserialize using protobuf-net code-first types, verifying wire compatibility without a running gRPC server. - ---- - -## Step 2: Write v2 proto file - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` - -Create the directory first: `mkdir -p src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos` - -Write the complete v2 proto file exactly as follows: - -```protobuf -syntax = "proto3"; -package scada; - -// ============================================================ -// Service Definition -// ============================================================ - -service ScadaService { - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} - -// ============================================================ -// Typed Value System -// ============================================================ - -message TypedValue { - oneof value { - bool bool_value = 1; - int32 int32_value = 2; - int64 int64_value = 3; - float float_value = 4; - double double_value = 5; - string string_value = 6; - bytes bytes_value = 7; - int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - ArrayValue array_value = 9; - } -} - -message ArrayValue { - oneof values { - BoolArray bool_values = 1; - Int32Array int32_values = 2; - Int64Array int64_values = 3; - FloatArray float_values = 4; - DoubleArray double_values = 5; - StringArray string_values = 6; - } -} - -message BoolArray { repeated bool values = 1; } -message Int32Array { repeated int32 values = 1; } -message Int64Array { repeated int64 values = 1; } -message FloatArray { repeated float values = 1; } -message DoubleArray { repeated double values = 1; } -message StringArray { repeated string values = 1; } - -// ============================================================ -// OPC UA-Style Quality Codes -// ============================================================ - -message QualityCode { - uint32 status_code = 1; - string symbolic_name = 2; -} - -// ============================================================ -// Connection Lifecycle -// ============================================================ - -message ConnectRequest { - string client_id = 1; - string api_key = 2; -} - -message ConnectResponse { - bool success = 1; - string message = 2; - string session_id = 3; -} - -message DisconnectRequest { - string session_id = 1; -} - -message DisconnectResponse { - bool success = 1; - string message = 2; -} - -message GetConnectionStateRequest { - string session_id = 1; -} - -message GetConnectionStateResponse { - bool is_connected = 1; - string client_id = 2; - int64 connected_since_utc_ticks = 3; -} - -message CheckApiKeyRequest { - string api_key = 1; -} - -message CheckApiKeyResponse { - bool is_valid = 1; - string message = 2; -} - -// ============================================================ -// Value-Timestamp-Quality -// ============================================================ - -message VtqMessage { - string tag = 1; - TypedValue value = 2; - int64 timestamp_utc_ticks = 3; - QualityCode quality = 4; -} - -// ============================================================ -// Read Operations -// ============================================================ - -message ReadRequest { - string session_id = 1; - string tag = 2; -} - -message ReadResponse { - bool success = 1; - string message = 2; - VtqMessage vtq = 3; -} - -message ReadBatchRequest { - string session_id = 1; - repeated string tags = 2; -} - -message ReadBatchResponse { - bool success = 1; - string message = 2; - repeated VtqMessage vtqs = 3; -} - -// ============================================================ -// Write Operations -// ============================================================ - -message WriteRequest { - string session_id = 1; - string tag = 2; - TypedValue value = 3; -} - -message WriteResponse { - bool success = 1; - string message = 2; -} - -message WriteItem { - string tag = 1; - TypedValue value = 2; -} - -message WriteResult { - string tag = 1; - bool success = 2; - string message = 3; -} - -message WriteBatchRequest { - string session_id = 1; - repeated WriteItem items = 2; -} - -message WriteBatchResponse { - bool success = 1; - string message = 2; - repeated WriteResult results = 3; -} - -// ============================================================ -// WriteBatchAndWait -// ============================================================ - -message WriteBatchAndWaitRequest { - string session_id = 1; - repeated WriteItem items = 2; - string flag_tag = 3; - TypedValue flag_value = 4; - int32 timeout_ms = 5; - int32 poll_interval_ms = 6; -} - -message WriteBatchAndWaitResponse { - bool success = 1; - string message = 2; - repeated WriteResult write_results = 3; - bool flag_reached = 4; - int32 elapsed_ms = 5; -} - -// ============================================================ -// Subscription -// ============================================================ - -message SubscribeRequest { - string session_id = 1; - repeated string tags = 2; - int32 sampling_ms = 3; -} -``` - ---- - -## Step 3: Host domain types - -All Host domain types go in namespace `ZB.MOM.WW.LmxProxy.Host.Domain`. - -### 3.1 Quality enum - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// OPC quality codes mapped to domain-level values. - /// The byte value matches the low-order byte of the OPC DA quality code, - /// enabling direct round-trip between the domain enum and the wire OPC DA byte. - /// - public enum Quality : byte - { - // ─────────────── Bad family (0-31) ─────────────── - /// 0x00 - Bad [Non-Specific] - Bad = 0, - - /// 0x01 - Unknown quality value - Unknown = 1, - - /// 0x04 - Bad [Configuration Error] - Bad_ConfigError = 4, - - /// 0x08 - Bad [Not Connected] - Bad_NotConnected = 8, - - /// 0x0C - Bad [Device Failure] - Bad_DeviceFailure = 12, - - /// 0x10 - Bad [Sensor Failure] - Bad_SensorFailure = 16, - - /// 0x14 - Bad [Last Known Value] - Bad_LastKnownValue = 20, - - /// 0x18 - Bad [Communication Failure] - Bad_CommFailure = 24, - - /// 0x1C - Bad [Out of Service] - Bad_OutOfService = 28, - - /// 0x20 - Bad [Waiting for Initial Data] - Bad_WaitingForInitialData = 32, - - // ──────────── Uncertain family (64-95) ─────────── - /// 0x40 - Uncertain [Non-Specific] - Uncertain = 64, - - /// 0x41 - Uncertain [Non-Specific] (Low Limited) - Uncertain_LowLimited = 65, - - /// 0x42 - Uncertain [Non-Specific] (High Limited) - Uncertain_HighLimited = 66, - - /// 0x43 - Uncertain [Non-Specific] (Constant) - Uncertain_Constant = 67, - - /// 0x44 - Uncertain [Last Usable] - Uncertain_LastUsable = 68, - - /// 0x45 - Uncertain [Last Usable] (Low Limited) - Uncertain_LastUsable_LL = 69, - - /// 0x46 - Uncertain [Last Usable] (High Limited) - Uncertain_LastUsable_HL = 70, - - /// 0x47 - Uncertain [Last Usable] (Constant) - Uncertain_LastUsable_Cnst = 71, - - /// 0x50 - Uncertain [Sensor Not Accurate] - Uncertain_SensorNotAcc = 80, - - /// 0x51 - Uncertain [Sensor Not Accurate] (Low Limited) - Uncertain_SensorNotAcc_LL = 81, - - /// 0x52 - Uncertain [Sensor Not Accurate] (High Limited) - Uncertain_SensorNotAcc_HL = 82, - - /// 0x53 - Uncertain [Sensor Not Accurate] (Constant) - Uncertain_SensorNotAcc_C = 83, - - /// 0x54 - Uncertain [EU Exceeded] - Uncertain_EuExceeded = 84, - - /// 0x55 - Uncertain [EU Exceeded] (Low Limited) - Uncertain_EuExceeded_LL = 85, - - /// 0x56 - Uncertain [EU Exceeded] (High Limited) - Uncertain_EuExceeded_HL = 86, - - /// 0x57 - Uncertain [EU Exceeded] (Constant) - Uncertain_EuExceeded_C = 87, - - /// 0x58 - Uncertain [Sub-Normal] - Uncertain_SubNormal = 88, - - /// 0x59 - Uncertain [Sub-Normal] (Low Limited) - Uncertain_SubNormal_LL = 89, - - /// 0x5A - Uncertain [Sub-Normal] (High Limited) - Uncertain_SubNormal_HL = 90, - - /// 0x5B - Uncertain [Sub-Normal] (Constant) - Uncertain_SubNormal_C = 91, - - // ─────────────── Good family (192-219) ──────────── - /// 0xC0 - Good [Non-Specific] - Good = 192, - - /// 0xC1 - Good [Non-Specific] (Low Limited) - Good_LowLimited = 193, - - /// 0xC2 - Good [Non-Specific] (High Limited) - Good_HighLimited = 194, - - /// 0xC3 - Good [Non-Specific] (Constant) - Good_Constant = 195, - - /// 0xD8 - Good [Local Override] - Good_LocalOverride = 216, - - /// 0xD9 - Good [Local Override] (Low Limited) - Good_LocalOverride_LL = 217, - - /// 0xDA - Good [Local Override] (High Limited) - Good_LocalOverride_HL = 218, - - /// 0xDB - Good [Local Override] (Constant) - Good_LocalOverride_C = 219 - } -} -``` - -### 3.2 QualityExtensions - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Extension methods for the enum. - /// - public static class QualityExtensions - { - /// Returns true if quality is in the Good family (byte >= 192). - public static bool IsGood(this Quality q) => (byte)q >= 192; - - /// Returns true if quality is in the Uncertain family (byte 64-127). - public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128; - - /// Returns true if quality is in the Bad family (byte < 64). - public static bool IsBad(this Quality q) => (byte)q < 64; - } -} -``` - -### 3.3 Vtq - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs` - -```csharp -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Value, Timestamp, and Quality structure for SCADA data. - /// - public readonly struct Vtq : IEquatable - { - /// Gets the value. Null represents an unset/missing value. - public object? Value { get; } - - /// Gets the UTC timestamp when the value was read. - public DateTime Timestamp { get; } - - /// Gets the quality of the value. - public Quality Quality { get; } - - public Vtq(object? value, DateTime timestamp, Quality quality) - { - Value = value; - Timestamp = timestamp; - Quality = quality; - } - - public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); - public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - - public bool Equals(Vtq other) => - Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; - - public override bool Equals(object obj) => obj is Vtq other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - int hashCode = Value != null ? Value.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); - hashCode = (hashCode * 397) ^ (int)Quality; - return hashCode; - } - } - - public override string ToString() => - $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; - - public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); - public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); - } -} -``` - -### 3.4 ConnectionState - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Represents the state of a SCADA client connection. - /// - public enum ConnectionState - { - Disconnected, - Connecting, - Connected, - Disconnecting, - Error, - Reconnecting - } -} -``` - -### 3.5 ConnectionStateChangedEventArgs - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs` - -```csharp -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Event arguments for SCADA client connection state changes. - /// - public class ConnectionStateChangedEventArgs : EventArgs - { - public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, - string? message = null) - { - PreviousState = previousState; - CurrentState = currentState; - Timestamp = DateTime.UtcNow; - Message = message; - } - - public ConnectionState PreviousState { get; } - public ConnectionState CurrentState { get; } - public DateTime Timestamp { get; } - public string? Message { get; } - } -} -``` - -### 3.6 IScadaClient interface - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs` - -This is the v2 interface. Note the `WriteBatchAndWaitAsync` signature now matches the v2 protocol semantics (write items, poll flagTag for flagValue). - -```csharp -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Interface for SCADA system clients (MxAccess wrapper). - /// - public interface IScadaClient : IAsyncDisposable - { - /// Gets whether the client is connected to MxAccess. - bool IsConnected { get; } - - /// Gets the current connection state. - ConnectionState ConnectionState { get; } - - /// Occurs when the connection state changes. - event EventHandler ConnectionStateChanged; - - /// Connects to MxAccess. - Task ConnectAsync(CancellationToken ct = default); - - /// Disconnects from MxAccess. - Task DisconnectAsync(CancellationToken ct = default); - - /// Reads a single tag value. - /// VTQ with typed value. - Task ReadAsync(string address, CancellationToken ct = default); - - /// Reads multiple tag values with semaphore-controlled concurrency. - /// Dictionary of address to VTQ. - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); - - /// Writes a single tag value. Value is a native .NET type (not string). - Task WriteAsync(string address, object value, CancellationToken ct = default); - - /// Writes multiple tag values with semaphore-controlled concurrency. - Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); - - /// - /// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires. - /// Returns (writeSuccess, flagReached, elapsedMs). - /// - /// Tag-value pairs to write. - /// Tag to poll after writes. - /// Expected value (type-aware comparison). - /// Max wait time in milliseconds. - /// Poll interval in milliseconds. - /// Cancellation token. - Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagTag, - object flagValue, - int timeoutMs, - int pollIntervalMs, - CancellationToken ct = default); - - /// Subscribes to value changes for specified addresses. - /// Subscription handle for unsubscribing. - Task SubscribeAsync( - IEnumerable addresses, - Action callback, - CancellationToken ct = default); - } -} -``` - -### 3.7 Placeholder Program.cs (so Host compiles) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host -{ - internal static class Program - { - static void Main(string[] args) - { - // Placeholder - Phase 3 will implement full Topshelf startup. - } - } -} -``` - -### 3.8 Placeholder appsettings.json (so Host compiles) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` - -```json -{ -} -``` - ---- - -## Step 4: Client domain types - -All Client domain types go in namespace `ZB.MOM.WW.LmxProxy.Client.Domain`. - -### 4.1 Quality enum - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs` - -Identical enum values as Host (same byte values), but in the Client namespace. File-scoped namespace for .NET 10: - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// -/// OPC-style quality codes for SCADA data values. -/// Byte value matches OPC DA quality low byte for direct round-trip. -/// -public enum Quality : byte -{ - // ─────────────── Bad family (0-31) ─────────────── - Bad = 0, - Bad_ConfigError = 4, - Bad_NotConnected = 8, - Bad_DeviceFailure = 12, - Bad_SensorFailure = 16, - Bad_LastKnownValue = 20, - Bad_CommFailure = 24, - Bad_OutOfService = 28, - Bad_WaitingForInitialData = 32, - - // ──────────── Uncertain family (64-95) ─────────── - Uncertain = 64, - Uncertain_LowLimited = 65, - Uncertain_HighLimited = 66, - Uncertain_Constant = 67, - Uncertain_LastUsable = 68, - Uncertain_LastUsable_LL = 69, - Uncertain_LastUsable_HL = 70, - Uncertain_LastUsable_Cnst = 71, - Uncertain_SensorNotAcc = 80, - Uncertain_SensorNotAcc_LL = 81, - Uncertain_SensorNotAcc_HL = 82, - Uncertain_SensorNotAcc_C = 83, - Uncertain_EuExceeded = 84, - Uncertain_EuExceeded_LL = 85, - Uncertain_EuExceeded_HL = 86, - Uncertain_EuExceeded_C = 87, - Uncertain_SubNormal = 88, - Uncertain_SubNormal_LL = 89, - Uncertain_SubNormal_HL = 90, - Uncertain_SubNormal_C = 91, - - // ─────────────── Good family (192-219) ──────────── - Good = 192, - Good_LowLimited = 193, - Good_HighLimited = 194, - Good_Constant = 195, - Good_LocalOverride = 216, - Good_LocalOverride_LL = 217, - Good_LocalOverride_HL = 218, - Good_LocalOverride_C = 219 -} -``` - -### 4.2 QualityExtensions - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Extension methods for . -public static class QualityExtensions -{ - /// Returns true if quality is in the Good family (byte >= 192). - public static bool IsGood(this Quality q) => (byte)q >= 192; - - /// Returns true if quality is in the Uncertain family (byte 64-127). - public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128; - - /// Returns true if quality is in the Bad family (byte < 64). - public static bool IsBad(this Quality q) => (byte)q < 64; -} -``` - -### 4.3 Vtq - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Value, Timestamp, and Quality for SCADA data. -public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) -{ - public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - - public override string ToString() => - $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; -} -``` - -### 4.4 ConnectionState - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Represents the state of a connection to the LmxProxy service. -public enum ConnectionState -{ - Disconnected, - Connecting, - Connected, - Disconnecting, - Error, - Reconnecting -} -``` - -### 4.5 ScadaContracts.cs (v2 code-first contracts) - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs` - -This is the complete v2 code-first contract file. Every `[DataMember(Order = N)]` matches the proto field number exactly. `TypedValue` and `QualityCode` are new additions. The `IScadaService` method names follow protobuf-net.Grpc conventions (operation name matching). - -```csharp -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.ServiceModel; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -// ──────────────────────────────────────────────────────────────── -// Service contract -// ──────────────────────────────────────────────────────────────── - -[ServiceContract(Name = "scada.ScadaService")] -public interface IScadaService -{ - ValueTask ConnectAsync(ConnectRequest request); - ValueTask DisconnectAsync(DisconnectRequest request); - ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); - ValueTask ReadAsync(ReadRequest request); - ValueTask ReadBatchAsync(ReadBatchRequest request); - ValueTask WriteAsync(WriteRequest request); - ValueTask WriteBatchAsync(WriteBatchRequest request); - ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); - IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); - ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); -} - -// ──────────────────────────────────────────────────────────────── -// Typed Value System (v2) -// ──────────────────────────────────────────────────────────────── - -/// -/// Carries a value in its native type via a protobuf oneof. -/// Exactly one property will be non-default. All-default = null value. -/// protobuf-net uses the first non-default field in field-number order for oneof. -/// -[DataContract] -public class TypedValue -{ - [DataMember(Order = 1)] - public bool BoolValue { get; set; } - - [DataMember(Order = 2)] - public int Int32Value { get; set; } - - [DataMember(Order = 3)] - public long Int64Value { get; set; } - - [DataMember(Order = 4)] - public float FloatValue { get; set; } - - [DataMember(Order = 5)] - public double DoubleValue { get; set; } - - [DataMember(Order = 6)] - public string? StringValue { get; set; } - - [DataMember(Order = 7)] - public byte[]? BytesValue { get; set; } - - [DataMember(Order = 8)] - public long DatetimeValue { get; set; } - - [DataMember(Order = 9)] - public ArrayValue? ArrayValue { get; set; } - - /// - /// Indicates which oneof case is set. Determined by checking non-default values. - /// This is NOT a wire field -- it's a convenience helper. - /// - public TypedValueCase GetValueCase() - { - // Check in reverse priority order to handle protobuf oneof semantics. - // For the oneof, only one should be set at a time. - if (ArrayValue != null) return TypedValueCase.ArrayValue; - if (DatetimeValue != 0) return TypedValueCase.DatetimeValue; - if (BytesValue != null) return TypedValueCase.BytesValue; - if (StringValue != null) return TypedValueCase.StringValue; - if (DoubleValue != 0d) return TypedValueCase.DoubleValue; - if (FloatValue != 0f) return TypedValueCase.FloatValue; - if (Int64Value != 0) return TypedValueCase.Int64Value; - if (Int32Value != 0) return TypedValueCase.Int32Value; - if (BoolValue) return TypedValueCase.BoolValue; - return TypedValueCase.None; - } -} - -/// Identifies which field in TypedValue is set. -public enum TypedValueCase -{ - None = 0, - BoolValue = 1, - Int32Value = 2, - Int64Value = 3, - FloatValue = 4, - DoubleValue = 5, - StringValue = 6, - BytesValue = 7, - DatetimeValue = 8, - ArrayValue = 9 -} - -/// Container for typed arrays. Exactly one field will be set. -[DataContract] -public class ArrayValue -{ - [DataMember(Order = 1)] - public BoolArray? BoolValues { get; set; } - - [DataMember(Order = 2)] - public Int32Array? Int32Values { get; set; } - - [DataMember(Order = 3)] - public Int64Array? Int64Values { get; set; } - - [DataMember(Order = 4)] - public FloatArray? FloatValues { get; set; } - - [DataMember(Order = 5)] - public DoubleArray? DoubleValues { get; set; } - - [DataMember(Order = 6)] - public StringArray? StringValues { get; set; } -} - -[DataContract] -public class BoolArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class Int32Array -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class Int64Array -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class FloatArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class DoubleArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class StringArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// Quality Code (v2) -// ──────────────────────────────────────────────────────────────── - -/// -/// OPC UA-style quality code with numeric status code and symbolic name. -/// -[DataContract] -public class QualityCode -{ - [DataMember(Order = 1)] - public uint StatusCode { get; set; } - - [DataMember(Order = 2)] - public string SymbolicName { get; set; } = string.Empty; - - /// Returns true if quality category is Good (high bits 0x00). - public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000; - - /// Returns true if quality category is Uncertain (high bits 0x40). - public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000; - - /// Returns true if quality category is Bad (high bits 0x80). - public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000; -} - -// ──────────────────────────────────────────────────────────────── -// VTQ message (v2) -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class VtqMessage -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public TypedValue? Value { get; set; } - - [DataMember(Order = 3)] - public long TimestampUtcTicks { get; set; } - - [DataMember(Order = 4)] - public QualityCode? Quality { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Connect -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class ConnectRequest -{ - [DataMember(Order = 1)] - public string ClientId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string ApiKey { get; set; } = string.Empty; -} - -[DataContract] -public class ConnectResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public string SessionId { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// Disconnect -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class DisconnectRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -[DataContract] -public class DisconnectResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// GetConnectionState -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class GetConnectionStateRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -[DataContract] -public class GetConnectionStateResponse -{ - [DataMember(Order = 1)] - public bool IsConnected { get; set; } - - [DataMember(Order = 2)] - public string ClientId { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public long ConnectedSinceUtcTicks { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Read -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class ReadRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; -} - -[DataContract] -public class ReadResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public VtqMessage? Vtq { get; set; } -} - -[DataContract] -public class ReadBatchRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Tags { get; set; } = []; -} - -[DataContract] -public class ReadBatchResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List Vtqs { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// Write -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class WriteRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public TypedValue? Value { get; set; } -} - -[DataContract] -public class WriteResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -[DataContract] -public class WriteItem -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public TypedValue? Value { get; set; } -} - -[DataContract] -public class WriteResult -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public bool Success { get; set; } - - [DataMember(Order = 3)] - public string Message { get; set; } = string.Empty; -} - -[DataContract] -public class WriteBatchRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Items { get; set; } = []; -} - -[DataContract] -public class WriteBatchResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List Results { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// WriteBatchAndWait -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class WriteBatchAndWaitRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Items { get; set; } = []; - - [DataMember(Order = 3)] - public string FlagTag { get; set; } = string.Empty; - - [DataMember(Order = 4)] - public TypedValue? FlagValue { get; set; } - - [DataMember(Order = 5)] - public int TimeoutMs { get; set; } - - [DataMember(Order = 6)] - public int PollIntervalMs { get; set; } -} - -[DataContract] -public class WriteBatchAndWaitResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List WriteResults { get; set; } = []; - - [DataMember(Order = 4)] - public bool FlagReached { get; set; } - - [DataMember(Order = 5)] - public int ElapsedMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Subscribe -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class SubscribeRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Tags { get; set; } = []; - - [DataMember(Order = 3)] - public int SamplingMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// CheckApiKey -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class CheckApiKeyRequest -{ - [DataMember(Order = 1)] - public string ApiKey { get; set; } = string.Empty; -} - -[DataContract] -public class CheckApiKeyResponse -{ - [DataMember(Order = 1)] - public bool IsValid { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} -``` - ---- - -## Step 5: COM variant coercion helpers (Host-side) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs` - -This static class converts between COM variant objects (boxed .NET types from MxAccess) and the proto-generated `Scada.TypedValue` message. The proto codegen will produce classes in namespace `Scada` (from `package scada;`). - -```csharp -using System; -using Google.Protobuf; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Converts between COM variant objects (boxed .NET types from MxAccess) - /// and proto-generated messages. - /// - public static class TypedValueConverter - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter)); - - /// - /// Converts a COM variant object to a proto TypedValue. - /// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL. - /// - public static Scada.TypedValue? ToTypedValue(object? value) - { - if (value == null || value is DBNull) - return null; - - switch (value) - { - case bool b: - return new Scada.TypedValue { BoolValue = b }; - - case short s: // VT_I2 → widened to int32 - return new Scada.TypedValue { Int32Value = s }; - - case int i: // VT_I4 - return new Scada.TypedValue { Int32Value = i }; - - case long l: // VT_I8 - return new Scada.TypedValue { Int64Value = l }; - - case ushort us: // VT_UI2 → widened to int32 - return new Scada.TypedValue { Int32Value = us }; - - case uint ui: // VT_UI4 → widened to int64 to avoid sign issues - return new Scada.TypedValue { Int64Value = ui }; - - case ulong ul: // VT_UI8 → int64, truncation risk - if (ul > (ulong)long.MaxValue) - Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul); - return new Scada.TypedValue { Int64Value = (long)ul }; - - case float f: // VT_R4 - return new Scada.TypedValue { FloatValue = f }; - - case double d: // VT_R8 - return new Scada.TypedValue { DoubleValue = d }; - - case string str: // VT_BSTR - return new Scada.TypedValue { StringValue = str }; - - case DateTime dt: // VT_DATE → UTC Ticks - return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks }; - - case decimal dec: // VT_DECIMAL → double (precision loss) - Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec); - return new Scada.TypedValue { DoubleValue = (double)dec }; - - case byte[] bytes: // VT_ARRAY of bytes - return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) }; - - case bool[] boolArr: - { - var arr = new Scada.BoolArray(); - arr.Values.AddRange(boolArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } }; - } - - case int[] intArr: - { - var arr = new Scada.Int32Array(); - arr.Values.AddRange(intArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } }; - } - - case long[] longArr: - { - var arr = new Scada.Int64Array(); - arr.Values.AddRange(longArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } }; - } - - case float[] floatArr: - { - var arr = new Scada.FloatArray(); - arr.Values.AddRange(floatArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } }; - } - - case double[] doubleArr: - { - var arr = new Scada.DoubleArray(); - arr.Values.AddRange(doubleArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } }; - } - - case string[] strArr: - { - var arr = new Scada.StringArray(); - arr.Values.AddRange(strArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } }; - } - - default: - // VT_UNKNOWN or any unrecognized type — ToString() fallback - Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name); - return new Scada.TypedValue { StringValue = value.ToString() }; - } - } - - /// - /// Converts a proto TypedValue back to a boxed .NET object. - /// Returns null for unset oneof (null TypedValue or ValueCase.None). - /// - public static object? FromTypedValue(Scada.TypedValue? typedValue) - { - if (typedValue == null) - return null; - - switch (typedValue.ValueCase) - { - case Scada.TypedValue.ValueOneofCase.BoolValue: - return typedValue.BoolValue; - - case Scada.TypedValue.ValueOneofCase.Int32Value: - return typedValue.Int32Value; - - case Scada.TypedValue.ValueOneofCase.Int64Value: - return typedValue.Int64Value; - - case Scada.TypedValue.ValueOneofCase.FloatValue: - return typedValue.FloatValue; - - case Scada.TypedValue.ValueOneofCase.DoubleValue: - return typedValue.DoubleValue; - - case Scada.TypedValue.ValueOneofCase.StringValue: - return typedValue.StringValue; - - case Scada.TypedValue.ValueOneofCase.BytesValue: - return typedValue.BytesValue.ToByteArray(); - - case Scada.TypedValue.ValueOneofCase.DatetimeValue: - return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc); - - case Scada.TypedValue.ValueOneofCase.ArrayValue: - return FromArrayValue(typedValue.ArrayValue); - - case Scada.TypedValue.ValueOneofCase.None: - default: - return null; - } - } - - private static object? FromArrayValue(Scada.ArrayValue? arrayValue) - { - if (arrayValue == null) - return null; - - switch (arrayValue.ValuesCase) - { - case Scada.ArrayValue.ValuesOneofCase.BoolValues: - return arrayValue.BoolValues?.Values?.Count > 0 - ? ToArray(arrayValue.BoolValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.Int32Values: - return arrayValue.Int32Values?.Values?.Count > 0 - ? ToArray(arrayValue.Int32Values.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.Int64Values: - return arrayValue.Int64Values?.Values?.Count > 0 - ? ToArray(arrayValue.Int64Values.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.FloatValues: - return arrayValue.FloatValues?.Values?.Count > 0 - ? ToArray(arrayValue.FloatValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.DoubleValues: - return arrayValue.DoubleValues?.Values?.Count > 0 - ? ToArray(arrayValue.DoubleValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.StringValues: - return arrayValue.StringValues?.Values?.Count > 0 - ? ToArray(arrayValue.StringValues.Values) - : Array.Empty(); - - default: - return null; - } - } - - private static T[] ToArray(Google.Protobuf.Collections.RepeatedField repeatedField) - { - var result = new T[repeatedField.Count]; - for (int i = 0; i < repeatedField.Count; i++) - result[i] = repeatedField[i]; - return result; - } - } -} -``` - ---- - -## Step 6: QualityCode helpers (Host-side) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs` - -Maps between the domain `Quality` enum and proto `QualityCode` messages. - -```csharp -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Maps between the domain enum and proto QualityCode messages. - /// status_code (uint32) is canonical. symbolic_name is derived from a lookup table. - /// - public static class QualityCodeMapper - { - /// OPC UA status code → symbolic name lookup. - private static readonly Dictionary StatusCodeToName = new Dictionary - { - // Good - { 0x00000000, "Good" }, - { 0x00D80000, "GoodLocalOverride" }, - - // Uncertain - { 0x40900000, "UncertainLastUsableValue" }, - { 0x42390000, "UncertainSensorNotAccurate" }, - { 0x40540000, "UncertainEngineeringUnitsExceeded" }, - { 0x40580000, "UncertainSubNormal" }, - - // Bad - { 0x80000000, "Bad" }, - { 0x80040000, "BadConfigurationError" }, - { 0x808A0000, "BadNotConnected" }, - { 0x806B0000, "BadDeviceFailure" }, - { 0x806D0000, "BadSensorFailure" }, - { 0x80050000, "BadCommunicationFailure" }, - { 0x808F0000, "BadOutOfService" }, - { 0x80320000, "BadWaitingForInitialData" }, - }; - - /// Domain Quality enum → OPC UA status code. - private static readonly Dictionary QualityToStatusCode = new Dictionary - { - // Good family - { Quality.Good, 0x00000000 }, - { Quality.Good_LowLimited, 0x00000000 }, - { Quality.Good_HighLimited, 0x00000000 }, - { Quality.Good_Constant, 0x00000000 }, - { Quality.Good_LocalOverride, 0x00D80000 }, - { Quality.Good_LocalOverride_LL, 0x00D80000 }, - { Quality.Good_LocalOverride_HL, 0x00D80000 }, - { Quality.Good_LocalOverride_C, 0x00D80000 }, - - // Uncertain family - { Quality.Uncertain, 0x40900000 }, - { Quality.Uncertain_LowLimited, 0x40900000 }, - { Quality.Uncertain_HighLimited, 0x40900000 }, - { Quality.Uncertain_Constant, 0x40900000 }, - { Quality.Uncertain_LastUsable, 0x40900000 }, - { Quality.Uncertain_LastUsable_LL, 0x40900000 }, - { Quality.Uncertain_LastUsable_HL, 0x40900000 }, - { Quality.Uncertain_LastUsable_Cnst, 0x40900000 }, - { Quality.Uncertain_SensorNotAcc, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_LL, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_HL, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_C, 0x42390000 }, - { Quality.Uncertain_EuExceeded, 0x40540000 }, - { Quality.Uncertain_EuExceeded_LL, 0x40540000 }, - { Quality.Uncertain_EuExceeded_HL, 0x40540000 }, - { Quality.Uncertain_EuExceeded_C, 0x40540000 }, - { Quality.Uncertain_SubNormal, 0x40580000 }, - { Quality.Uncertain_SubNormal_LL, 0x40580000 }, - { Quality.Uncertain_SubNormal_HL, 0x40580000 }, - { Quality.Uncertain_SubNormal_C, 0x40580000 }, - - // Bad family - { Quality.Bad, 0x80000000 }, - { Quality.Unknown, 0x80000000 }, - { Quality.Bad_ConfigError, 0x80040000 }, - { Quality.Bad_NotConnected, 0x808A0000 }, - { Quality.Bad_DeviceFailure, 0x806B0000 }, - { Quality.Bad_SensorFailure, 0x806D0000 }, - { Quality.Bad_LastKnownValue, 0x80050000 }, - { Quality.Bad_CommFailure, 0x80050000 }, - { Quality.Bad_OutOfService, 0x808F0000 }, - { Quality.Bad_WaitingForInitialData, 0x80320000 }, - }; - - /// - /// Converts a domain Quality enum to a proto QualityCode message. - /// - public static Scada.QualityCode ToQualityCode(Quality quality) - { - var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u; - var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad"; - - return new Scada.QualityCode - { - StatusCode = statusCode, - SymbolicName = symbolicName - }; - } - - /// - /// Converts an OPC UA status code (uint32) to a domain Quality enum. - /// Falls back to the nearest category if the specific code is not mapped. - /// - public static Quality FromStatusCode(uint statusCode) - { - // Exact match first — iterate QualityToStatusCode to find matching Quality - foreach (var kvp in QualityToStatusCode) - { - if (kvp.Value == statusCode) - return kvp.Key; - } - - // Category fallback - uint category = statusCode & 0xC0000000; - if (category == 0x00000000) return Quality.Good; - if (category == 0x40000000) return Quality.Uncertain; - return Quality.Bad; - } - - /// - /// Gets the symbolic name for a status code. - /// - public static string GetSymbolicName(uint statusCode) - { - if (StatusCodeToName.TryGetValue(statusCode, out var name)) - return name; - - uint category = statusCode & 0xC0000000; - if (category == 0x00000000) return "Good"; - if (category == 0x40000000) return "Uncertain"; - return "Bad"; - } - - /// - /// Creates a QualityCode for a specific well-known status. - /// - public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; - public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; - public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" }; - public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }; - public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" }; - public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" }; - public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }; - public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" }; - public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" }; - public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" }; - public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; - } -} -``` - ---- - -## Step 7: Unit tests - -### 7.1 Host.Tests: TypedValueConverter tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs` - -```csharp -using System; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class TypedValueConverterTests - { - [Fact] - public void Null_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(null); - tv.Should().BeNull(); - TypedValueConverter.FromTypedValue(null).Should().BeNull(); - } - - [Fact] - public void DBNull_MapsToNull() - { - var tv = TypedValueConverter.ToTypedValue(DBNull.Value); - tv.Should().BeNull(); - } - - [Fact] - public void Bool_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(true); - tv.Should().NotBeNull(); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue); - tv.BoolValue.Should().BeTrue(); - TypedValueConverter.FromTypedValue(tv).Should().Be(true); - - var tvFalse = TypedValueConverter.ToTypedValue(false); - tvFalse!.BoolValue.Should().BeFalse(); - TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false); - } - - [Fact] - public void Short_WidensToInt32() - { - var tv = TypedValueConverter.ToTypedValue((short)42); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(42); - TypedValueConverter.FromTypedValue(tv).Should().Be(42); - } - - [Fact] - public void Int_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(int.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(int.MaxValue); - TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue); - } - - [Fact] - public void Long_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(long.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(long.MaxValue); - TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue); - } - - [Fact] - public void UShort_WidensToInt32() - { - var tv = TypedValueConverter.ToTypedValue((ushort)65535); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(65535); - } - - [Fact] - public void UInt_WidensToInt64() - { - var tv = TypedValueConverter.ToTypedValue(uint.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(uint.MaxValue); - } - - [Fact] - public void ULong_MapsToInt64() - { - var tv = TypedValueConverter.ToTypedValue((ulong)12345678); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(12345678); - } - - [Fact] - public void Float_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(3.14159f); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue); - tv.FloatValue.Should().Be(3.14159f); - TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f); - } - - [Fact] - public void Double_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(2.718281828459045); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); - tv.DoubleValue.Should().Be(2.718281828459045); - TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045); - } - - [Fact] - public void String_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue("Hello World"); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); - tv.StringValue.Should().Be("Hello World"); - TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World"); - } - - [Fact] - public void DateTime_RoundTrips_AsUtcTicks() - { - var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc); - var tv = TypedValueConverter.ToTypedValue(dt); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue); - tv.DatetimeValue.Should().Be(dt.Ticks); - var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!; - result.Kind.Should().Be(DateTimeKind.Utc); - result.Ticks.Should().Be(dt.Ticks); - } - - [Fact] - public void ByteArray_RoundTrips() - { - var bytes = new byte[] { 0x00, 0xFF, 0x42 }; - var tv = TypedValueConverter.ToTypedValue(bytes); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue); - var result = (byte[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(bytes); - } - - [Fact] - public void Decimal_MapsToDouble() - { - var tv = TypedValueConverter.ToTypedValue(123.456m); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); - tv.DoubleValue.Should().BeApproximately(123.456, 0.001); - } - - [Fact] - public void FloatArray_RoundTrips() - { - var arr = new float[] { 1.0f, 2.0f, 3.0f }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (float[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void IntArray_RoundTrips() - { - var arr = new int[] { 10, 20, 30 }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (int[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void StringArray_RoundTrips() - { - var arr = new string[] { "a", "b", "c" }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (string[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void DoubleArray_RoundTrips() - { - var arr = new double[] { 1.1, 2.2, 3.3 }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (double[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void UnrecognizedType_FallsBackToString() - { - var guid = Guid.NewGuid(); - var tv = TypedValueConverter.ToTypedValue(guid); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); - tv.StringValue.Should().Be(guid.ToString()); - } - } -} -``` - -### 7.2 Host.Tests: QualityCodeMapper tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs` - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class QualityCodeMapperTests - { - [Theory] - [InlineData(Quality.Good, 0x00000000u, "Good")] - [InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")] - [InlineData(Quality.Bad, 0x80000000u, "Bad")] - [InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")] - [InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")] - [InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")] - [InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")] - [InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")] - [InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")] - [InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")] - [InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")] - [InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")] - [InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")] - [InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")] - public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName) - { - var qc = QualityCodeMapper.ToQualityCode(quality); - qc.StatusCode.Should().Be(expectedStatusCode); - qc.SymbolicName.Should().Be(expectedName); - } - - [Theory] - [InlineData(0x00000000u, Quality.Good)] - [InlineData(0x80000000u, Quality.Bad)] - [InlineData(0x80040000u, Quality.Bad_ConfigError)] - [InlineData(0x806D0000u, Quality.Bad_SensorFailure)] - [InlineData(0x40900000u, Quality.Uncertain_LastUsable)] - public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality) - { - QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality); - } - - [Fact] - public void FromStatusCode_UnknownGoodCode_FallsBackToGood() - { - QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good); - } - - [Fact] - public void FromStatusCode_UnknownBadCode_FallsBackToBad() - { - QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad); - } - - [Fact] - public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain() - { - QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain); - } - - [Theory] - [InlineData(0x00000000u, "Good")] - [InlineData(0x80000000u, "Bad")] - [InlineData(0x806D0000u, "BadSensorFailure")] - [InlineData(0x40900000u, "UncertainLastUsableValue")] - [InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back - public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName) - { - QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName); - } - - [Fact] - public void FactoryMethods_ReturnCorrectCodes() - { - QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u); - QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u); - QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u); - QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u); - QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u); - QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u); - QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u); - QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u); - QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u); - QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u); - QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u); - } - } -} -``` - -### 7.3 Host.Tests: Quality extensions tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs` - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class QualityExtensionsTests - { - [Theory] - [InlineData(Quality.Good, true)] - [InlineData(Quality.Good_LocalOverride, true)] - [InlineData(Quality.Uncertain, false)] - [InlineData(Quality.Bad, false)] - public void IsGood(Quality q, bool expected) - { - q.IsGood().Should().Be(expected); - } - - [Theory] - [InlineData(Quality.Uncertain, true)] - [InlineData(Quality.Uncertain_LastUsable, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Bad, false)] - public void IsUncertain(Quality q, bool expected) - { - q.IsUncertain().Should().Be(expected); - } - - [Theory] - [InlineData(Quality.Bad, true)] - [InlineData(Quality.Bad_CommFailure, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Uncertain, false)] - public void IsBad(Quality q, bool expected) - { - q.IsBad().Should().Be(expected); - } - } -} -``` - -### 7.4 Client.Tests: ScadaContracts tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs` - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class ScadaContractsTests -{ - [Fact] - public void TypedValue_GetValueCase_Bool() - { - var tv = new TypedValue { BoolValue = true }; - tv.GetValueCase().Should().Be(TypedValueCase.BoolValue); - } - - [Fact] - public void TypedValue_GetValueCase_Int32() - { - var tv = new TypedValue { Int32Value = 42 }; - tv.GetValueCase().Should().Be(TypedValueCase.Int32Value); - } - - [Fact] - public void TypedValue_GetValueCase_Double() - { - var tv = new TypedValue { DoubleValue = 3.14 }; - tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue); - } - - [Fact] - public void TypedValue_GetValueCase_String() - { - var tv = new TypedValue { StringValue = "hello" }; - tv.GetValueCase().Should().Be(TypedValueCase.StringValue); - } - - [Fact] - public void TypedValue_GetValueCase_None_WhenDefault() - { - var tv = new TypedValue(); - tv.GetValueCase().Should().Be(TypedValueCase.None); - } - - [Fact] - public void TypedValue_GetValueCase_Datetime() - { - var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks }; - tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue); - } - - [Fact] - public void TypedValue_GetValueCase_BytesValue() - { - var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } }; - tv.GetValueCase().Should().Be(TypedValueCase.BytesValue); - } - - [Fact] - public void TypedValue_GetValueCase_ArrayValue() - { - var tv = new TypedValue - { - ArrayValue = new ArrayValue - { - FloatValues = new FloatArray { Values = { 1.0f, 2.0f } } - } - }; - tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue); - } - - [Fact] - public void QualityCode_IsGood() - { - var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; - qc.IsGood.Should().BeTrue(); - qc.IsBad.Should().BeFalse(); - qc.IsUncertain.Should().BeFalse(); - } - - [Fact] - public void QualityCode_IsBad() - { - var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; - qc.IsGood.Should().BeFalse(); - qc.IsBad.Should().BeTrue(); - qc.IsUncertain.Should().BeFalse(); - } - - [Fact] - public void QualityCode_IsUncertain() - { - var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; - qc.IsGood.Should().BeFalse(); - qc.IsBad.Should().BeFalse(); - qc.IsUncertain.Should().BeTrue(); - } - - [Fact] - public void VtqMessage_DefaultProperties() - { - var vtq = new VtqMessage(); - vtq.Tag.Should().BeEmpty(); - vtq.Value.Should().BeNull(); - vtq.TimestampUtcTicks.Should().Be(0); - vtq.Quality.Should().BeNull(); - } - - [Fact] - public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue() - { - var req = new WriteBatchAndWaitRequest - { - SessionId = "abc", - FlagTag = "Motor.Done", - FlagValue = new TypedValue { BoolValue = true }, - TimeoutMs = 5000, - PollIntervalMs = 100 - }; - req.FlagValue.Should().NotBeNull(); - req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue); - } - - [Fact] - public void WriteItem_Value_IsTypedValue() - { - var item = new WriteItem - { - Tag = "Motor.Speed", - Value = new TypedValue { DoubleValue = 42.5 } - }; - item.Value.Should().NotBeNull(); - item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue); - } -} -``` - -### 7.5 Client.Tests: Quality extensions tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs` - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class QualityExtensionsTests -{ - [Theory] - [InlineData(Quality.Good, true)] - [InlineData(Quality.Good_LocalOverride, true)] - [InlineData(Quality.Uncertain, false)] - [InlineData(Quality.Bad, false)] - public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected); - - [Theory] - [InlineData(Quality.Uncertain, true)] - [InlineData(Quality.Uncertain_LastUsable, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Bad, false)] - public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected); - - [Theory] - [InlineData(Quality.Bad, true)] - [InlineData(Quality.Bad_CommFailure, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Uncertain, false)] - public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected); -} -``` - -### 7.6 Client.Tests: Vtq factory methods - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs` - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class VtqTests -{ - [Fact] - public void Good_FactoryMethod() - { - var vtq = Vtq.Good(42.0); - vtq.Value.Should().Be(42.0); - vtq.Quality.Should().Be(Quality.Good); - vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc); - } - - [Fact] - public void Bad_FactoryMethod() - { - var vtq = Vtq.Bad(); - vtq.Value.Should().BeNull(); - vtq.Quality.Should().Be(Quality.Bad); - } - - [Fact] - public void Uncertain_FactoryMethod() - { - var vtq = Vtq.Uncertain("stale"); - vtq.Value.Should().Be("stale"); - vtq.Quality.Should().Be(Quality.Uncertain); - } -} -``` - ---- - -## Step 8: Cross-stack contract tests - -These tests verify that bytes serialized by Host proto-generated code can be deserialized by Client code-first code (and vice versa). The Client.Tests project includes the Host's `scada.proto` with `GrpcServices="None"` to get the Google.Protobuf generated message classes. - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs` - -```csharp -using System.IO; -using FluentAssertions; -using Google.Protobuf; -using ProtoBuf; -using Xunit; -using ProtoGenerated = Scada; -using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack; - -/// -/// Verifies wire compatibility between Host proto-generated types and Client code-first types. -/// Serializes with one stack, deserializes with the other. -/// -public class CrossStackSerializationTests -{ - // ── Proto-generated → Code-first ────────────────────────── - - [Fact] - public void VtqMessage_ProtoToCodeFirst_BoolValue() - { - // Arrange: proto-generated VtqMessage with bool TypedValue - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Running", - Value = new ProtoGenerated.TypedValue { BoolValue = true }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - // Act: serialize with proto, deserialize with protobuf-net - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - // Assert - codeFirst.Should().NotBeNull(); - codeFirst.Tag.Should().Be("Motor.Running"); - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.BoolValue.Should().BeTrue(); - codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L); - codeFirst.Quality.Should().NotBeNull(); - codeFirst.Quality!.StatusCode.Should().Be(0x00000000u); - codeFirst.Quality.SymbolicName.Should().Be("Good"); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_DoubleValue() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Speed", - Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.DoubleValue.Should().Be(42.5); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_StringValue() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Name", - Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.StringValue.Should().Be("Pump A"); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_Int32Value() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Count", - Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value!.Int32Value.Should().Be(int.MaxValue); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_BadQuality() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Fault", - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u); - codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure"); - codeFirst.Quality.IsBad.Should().BeTrue(); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_NullValue() - { - // No Value field set — represents null - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Optional", - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - // When no oneof is set, the Value object may be null or all-default - // Either way, GetValueCase() should return None - if (codeFirst.Value != null) - codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_FloatArrayValue() - { - var floatArr = new ProtoGenerated.FloatArray(); - floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f }); - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Samples", - Value = new ProtoGenerated.TypedValue - { - ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr } - }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.ArrayValue.Should().NotBeNull(); - codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull(); - codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f }); - } - - // ── Code-first → Proto-generated ────────────────────────── - - [Fact] - public void VtqMessage_CodeFirstToProto_DoubleValue() - { - var codeFirst = new CodeFirst.VtqMessage - { - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 99.9 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - // Serialize with protobuf-net - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var bytes = ms.ToArray(); - - // Deserialize with Google.Protobuf - var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes); - - protoMsg.Tag.Should().Be("Motor.Speed"); - protoMsg.Value.Should().NotBeNull(); - protoMsg.Value.DoubleValue.Should().Be(99.9); - protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L); - protoMsg.Quality.StatusCode.Should().Be(0x00000000u); - } - - [Fact] - public void WriteRequest_CodeFirstToProto() - { - var codeFirst = new CodeFirst.WriteRequest - { - SessionId = "abc123", - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 42.5 } - }; - - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var bytes = ms.ToArray(); - - var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes); - protoMsg.SessionId.Should().Be("abc123"); - protoMsg.Tag.Should().Be("Motor.Speed"); - protoMsg.Value.Should().NotBeNull(); - protoMsg.Value.DoubleValue.Should().Be(42.5); - } - - [Fact] - public void ConnectRequest_RoundTrips() - { - var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" }; - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray()); - protoMsg.ClientId.Should().Be("ScadaLink-1"); - protoMsg.ApiKey.Should().Be("key-123"); - } - - [Fact] - public void ConnectResponse_RoundTrips() - { - var protoMsg = new ProtoGenerated.ConnectResponse - { - Success = true, - Message = "Connected", - SessionId = "abcdef1234567890abcdef1234567890" - }; - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - codeFirst.Success.Should().BeTrue(); - codeFirst.Message.Should().Be("Connected"); - codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890"); - } - - [Fact] - public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue() - { - var codeFirst = new CodeFirst.WriteBatchAndWaitRequest - { - SessionId = "sess1", - FlagTag = "Motor.Done", - FlagValue = new CodeFirst.TypedValue { BoolValue = true }, - TimeoutMs = 5000, - PollIntervalMs = 100, - Items = - { - new CodeFirst.WriteItem - { - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 50.0 } - } - } - }; - - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray()); - - protoMsg.FlagTag.Should().Be("Motor.Done"); - protoMsg.FlagValue.BoolValue.Should().BeTrue(); - protoMsg.TimeoutMs.Should().Be(5000); - protoMsg.PollIntervalMs.Should().Be(100); - protoMsg.Items.Should().HaveCount(1); - protoMsg.Items[0].Tag.Should().Be("Motor.Speed"); - protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0); - } -} -``` - -**Important note on protobuf-net oneof handling**: protobuf-net's code-first approach does not have native `oneof` support. Instead, it serializes whichever fields have non-default values. For `TypedValue`, only one field should be set at a time to maintain oneof semantics. The cross-stack tests verify this works in practice. If tests fail due to protobuf-net serializing multiple fields when only one is set (e.g., `BoolValue = false` serialized as field 1 with value 0), you may need to add `[ProtoMember]` attributes with `ShouldSerialize*` methods or use `[ProtoContract(SkipConstructor = true)]`. Investigate and fix if needed — the cross-stack tests exist precisely to catch these issues. - ---- - -## Step 9: Build verification - -Run these commands from the repository root (`/Users/dohertj2/Desktop/scadalink-design/lmxproxy`): - -```bash -# Verify solution builds (Host will likely fail on macOS due to net48/x86 — that's expected) -dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj - -# Run Client tests (these should work on macOS) -dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj - -# If on Windows, also run: -# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj -# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj -``` - -**Note on build platform**: The Host project targets `net48` with `PlatformTarget=x86`, which requires Windows with .NET Framework 4.8 SDK. On macOS, only the Client project and Client.Tests will build. The Host will be verified on the Windows development machine (windev). The cross-stack serialization tests in Client.Tests use the proto file directly (via `Grpc.Tools` codegen which works on all platforms for message-only generation) so they can run on macOS. - ---- - -## Completion Criteria - -- [ ] `src-reference/` contains old code, `src/` contains fresh v2 code only -- [ ] Solution file references all 4 projects (Host, Client, Host.Tests, Client.Tests) -- [ ] Proto file at `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` matches v2 spec exactly -- [ ] Host domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ConnectionStateChangedEventArgs, IScadaClient -- [ ] Client domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ScadaContracts (all v2 messages) -- [ ] TypedValueConverter handles all COM variant types from the coercion table -- [ ] QualityCodeMapper maps all AVEVA-relevant quality codes bidirectionally -- [ ] All Host.Tests pass (TypedValueConverter, QualityCodeMapper, QualityExtensions) -- [ ] All Client.Tests pass (ScadaContracts, QualityExtensions, Vtq factory methods) -- [ ] Cross-stack serialization tests pass (proto-generated ↔ code-first) -- [ ] No references to `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` in any new file -- [ ] No string serialization heuristics (`double.TryParse`, `bool.TryParse`) in any new file diff --git a/deprecated/lmxproxy/docs/plans/phase-2-host-core.md b/deprecated/lmxproxy/docs/plans/phase-2-host-core.md deleted file mode 100644 index fb723ee..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-2-host-core.md +++ /dev/null @@ -1,2067 +0,0 @@ -# Phase 2: Host Core Components — Implementation Plan - -## Prerequisites - -- Phase 1 complete and passing: all projects build, all unit tests pass, cross-stack serialization verified. -- The following Phase 1 artifacts exist and are used throughout this phase: - - `src/ZB.MOM.WW.LmxProxy.Host/Domain/` — Quality, Vtq, ConnectionState, IScadaClient, TypedValueConverter, QualityCodeMapper - - `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` — v2 proto (generates `Scada.*` classes) - -## Guardrails - -1. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the STA dispatch queue. -2. **No v1 code** — reference `src-reference/` for patterns but write fresh. -3. **status_code is canonical for quality** — use `QualityCodeMapper` for all quality conversions. -4. **No string serialization heuristics** — use `TypedValueConverter` for all value conversions. -5. **Unit tests for every component** — test before moving to Phase 3. -6. **Each step must compile** before proceeding to the next. - ---- - -## Step 1: MxAccessClient — STA Dispatch Thread - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaDispatchThread.cs` - -**Namespace**: `ZB.MOM.WW.LmxProxy.Host.MxAccess` - -This is the foundation for all COM interop. MxAccess is an STA COM component — all COM calls must execute on a dedicated STA thread with a message pump. - -### Class Design - -```csharp -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - /// - /// Dedicated STA thread with a message pump for COM interop. - /// All COM operations are dispatched to this thread via a BlockingCollection. - /// - public sealed class StaDispatchThread : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly BlockingCollection _workQueue = new BlockingCollection(); - private readonly Thread _staThread; - private volatile bool _disposed; - - public StaDispatchThread(string threadName = "MxAccess-STA") - { - _staThread = new Thread(StaThreadLoop) - { - Name = threadName, - IsBackground = true - }; - _staThread.SetApartmentState(ApartmentState.STA); - _staThread.Start(); - Log.Information("STA dispatch thread '{ThreadName}' started", threadName); - } - - /// - /// Dispatches an action to the STA thread and returns a Task that completes - /// when the action finishes. - /// - public Task DispatchAsync(Action action) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _workQueue.Add(() => - { - try - { - action(); - tcs.TrySetResult(true); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - return tcs.Task; - } - - /// - /// Dispatches a function to the STA thread and returns its result. - /// - public Task DispatchAsync(Func func) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread)); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _workQueue.Add(() => - { - try - { - var result = func(); - tcs.TrySetResult(result); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - return tcs.Task; - } - - private void StaThreadLoop() - { - Log.Debug("STA thread loop started"); - - // Process the work queue. GetConsumingEnumerable blocks until - // items are available or the collection is marked complete. - foreach (var action in _workQueue.GetConsumingEnumerable()) - { - try - { - action(); - } - catch (Exception ex) - { - // Should not happen — actions set TCS exceptions internally. - Log.Error(ex, "Unhandled exception on STA thread"); - } - - // Pump COM messages between work items - Application.DoEvents(); - } - - Log.Debug("STA thread loop exited"); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _workQueue.CompleteAdding(); - - // Wait for the STA thread to drain and exit - if (_staThread.IsAlive && !_staThread.Join(TimeSpan.FromSeconds(10))) - { - Log.Warning("STA thread did not exit within 10 seconds"); - } - - _workQueue.Dispose(); - Log.Information("STA dispatch thread disposed"); - } - } -} -``` - -**Key design decisions**: -- `BlockingCollection` is the dispatch queue (thread-safe, blocking consumer). -- `TaskCompletionSource` bridges the STA thread back to async callers. -- `Application.DoEvents()` pumps COM messages between work items (required for MxAccess callbacks like OnDataChange). -- `RunContinuationsAsynchronously` prevents continuations from running on the STA thread. -- On dispose, `CompleteAdding()` signals the loop to exit, then `Join(10s)` waits for drain. - -**Dependency**: The Host project already references `System.Windows.Forms` implicitly through .NET Framework 4.8. If the build fails with a missing reference, add `` to the csproj ``. - ---- - -## Step 2: MxAccessClient — Connection - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` (main partial class) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs` (connection partial) - -### 2.1 Main class file - -```csharp -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - /// - /// Wraps the ArchestrA MXAccess COM API. All COM operations - /// execute on a dedicated STA thread via . - /// - public sealed partial class MxAccessClient : IScadaClient - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly StaDispatchThread _staThread; - private readonly object _lock = new object(); - private readonly int _maxConcurrentOperations; - private readonly int _readTimeoutMs; - private readonly int _writeTimeoutMs; - private readonly int _monitorIntervalMs; - private readonly bool _autoReconnect; - private readonly string? _nodeName; - private readonly string? _galaxyName; - - private readonly SemaphoreSlim _readSemaphore; - private readonly SemaphoreSlim _writeSemaphore; - - // COM objects — only accessed on STA thread - private ArchestrA.MxAccess.LMXProxyServerClass? _lmxProxy; - private int _connectionHandle; - - // State - private ConnectionState _connectionState = ConnectionState.Disconnected; - private DateTime _connectedSince; - private bool _disposed; - - // Reconnect - private CancellationTokenSource? _reconnectCts; - - // Stored subscriptions for reconnect replay - private readonly Dictionary> _storedSubscriptions - = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - public MxAccessClient( - int maxConcurrentOperations = 10, - int readTimeoutSeconds = 5, - int writeTimeoutSeconds = 5, - int monitorIntervalSeconds = 5, - bool autoReconnect = true, - string? nodeName = null, - string? galaxyName = null) - { - _maxConcurrentOperations = maxConcurrentOperations; - _readTimeoutMs = readTimeoutSeconds * 1000; - _writeTimeoutMs = writeTimeoutSeconds * 1000; - _monitorIntervalMs = monitorIntervalSeconds * 1000; - _autoReconnect = autoReconnect; - _nodeName = nodeName; - _galaxyName = galaxyName; - - _readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); - _writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); - _staThread = new StaDispatchThread(); - } - - public bool IsConnected - { - get - { - lock (_lock) - { - return _lmxProxy != null - && _connectionState == ConnectionState.Connected - && _connectionHandle > 0; - } - } - } - - public ConnectionState ConnectionState - { - get { lock (_lock) { return _connectionState; } } - } - - public event EventHandler? ConnectionStateChanged; - - private void SetState(ConnectionState newState, string? message = null) - { - ConnectionState previousState; - lock (_lock) - { - previousState = _connectionState; - _connectionState = newState; - } - - if (previousState != newState) - { - Log.Information("Connection state changed: {Previous} -> {Current} {Message}", - previousState, newState, message ?? ""); - ConnectionStateChanged?.Invoke(this, - new ConnectionStateChangedEventArgs(previousState, newState, message)); - } - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - _reconnectCts?.Cancel(); - - try - { - await DisconnectAsync(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during disposal disconnect"); - } - - _readSemaphore.Dispose(); - _writeSemaphore.Dispose(); - _staThread.Dispose(); - _reconnectCts?.Dispose(); - } - } -} -``` - -### 2.2 Connection partial class - -```csharp -using System; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Connects to MxAccess on the STA thread. - /// - public async Task ConnectAsync(CancellationToken ct = default) - { - if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient)); - if (IsConnected) return; - - SetState(ConnectionState.Connecting); - - try - { - await _staThread.DispatchAsync(() => - { - // Create COM object - _lmxProxy = new ArchestrA.MxAccess.LMXProxyServerClass(); - - // Wire event handlers - _lmxProxy.DataChanged += OnDataChange; - _lmxProxy.WriteCompleted += OnWriteComplete; - - // Register with MxAccess - _connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host"); - }); - - lock (_lock) - { - _connectedSince = DateTime.UtcNow; - } - - SetState(ConnectionState.Connected); - Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle); - - // Recreate any stored subscriptions from a previous connection - await RecreateStoredSubscriptionsAsync(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to connect to MxAccess"); - await CleanupComObjectsAsync(); - SetState(ConnectionState.Error, ex.Message); - throw; - } - } - - /// - /// Disconnects from MxAccess on the STA thread. - /// - public async Task DisconnectAsync(CancellationToken ct = default) - { - if (!IsConnected) return; - - SetState(ConnectionState.Disconnecting); - - try - { - await _staThread.DispatchAsync(() => - { - if (_lmxProxy != null && _connectionHandle > 0) - { - try - { - // Remove event handlers first - _lmxProxy.DataChanged -= OnDataChange; - _lmxProxy.WriteCompleted -= OnWriteComplete; - - // Unregister - _lmxProxy.Unregister(_connectionHandle); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during MxAccess unregister"); - } - finally - { - // Force-release COM object - Marshal.ReleaseComObject(_lmxProxy); - _lmxProxy = null; - _connectionHandle = 0; - } - } - }); - - SetState(ConnectionState.Disconnected); - Log.Information("Disconnected from MxAccess"); - } - catch (Exception ex) - { - Log.Error(ex, "Error during disconnect"); - SetState(ConnectionState.Error, ex.Message); - } - } - - /// - /// Starts the auto-reconnect monitor loop. - /// Call this after initial ConnectAsync succeeds. - /// - public void StartMonitorLoop() - { - if (!_autoReconnect) return; - - _reconnectCts = new CancellationTokenSource(); - Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token)); - } - - /// - /// Stops the auto-reconnect monitor loop. - /// Waits up to 5 seconds for the loop to exit. - /// - public void StopMonitorLoop() - { - _reconnectCts?.Cancel(); - } - - /// - /// Auto-reconnect monitor loop. Checks connection every monitorInterval. - /// On disconnect, attempts reconnect. On failure, retries at next interval. - /// - private async Task MonitorConnectionAsync(CancellationToken ct) - { - Log.Information("Connection monitor loop started (interval={IntervalMs}ms)", _monitorIntervalMs); - - while (!ct.IsCancellationRequested) - { - try - { - await Task.Delay(_monitorIntervalMs, ct); - } - catch (OperationCanceledException) - { - break; - } - - if (IsConnected) continue; - - Log.Information("MxAccess disconnected, attempting reconnect..."); - SetState(ConnectionState.Reconnecting); - - try - { - await ConnectAsync(ct); - Log.Information("Reconnected to MxAccess successfully"); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Log.Warning(ex, "Reconnect attempt failed, will retry in {IntervalMs}ms", _monitorIntervalMs); - } - } - - Log.Information("Connection monitor loop exited"); - } - - /// - /// Cleans up COM objects on the STA thread after a failed connection. - /// - private async Task CleanupComObjectsAsync() - { - try - { - await _staThread.DispatchAsync(() => - { - if (_lmxProxy != null) - { - try { _lmxProxy.DataChanged -= OnDataChange; } catch { } - try { _lmxProxy.WriteCompleted -= OnWriteComplete; } catch { } - try { Marshal.ReleaseComObject(_lmxProxy); } catch { } - _lmxProxy = null; - } - _connectionHandle = 0; - }); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during COM object cleanup"); - } - } - - /// Gets the UTC time when the connection was established. - public DateTime ConnectedSince - { - get { lock (_lock) { return _connectedSince; } } - } - } -} -``` - -**Note**: The exact COM interop method names (`Register`, `Unregister`, `DataChanged`, `WriteCompleted`) come from the ArchestrA.MXAccess COM interop assembly. Consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs` for the exact method signatures and event wiring patterns. The reference code uses `_lmxProxy.DataChanged += OnDataChange` style — match that exactly. - ---- - -## Step 3: MxAccessClient — Read/Write - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Reads a single tag value from MxAccess. - /// Dispatched to STA thread with semaphore concurrency control. - /// - public async Task ReadAsync(string address, CancellationToken ct = default) - { - if (!IsConnected) - return Vtq.New(null, Quality.Bad_NotConnected); - - await _readSemaphore.WaitAsync(ct); - try - { - return await _staThread.DispatchAsync(() => ReadInternal(address)); - } - catch (Exception ex) - { - Log.Error(ex, "ReadAsync failed for tag {Address}", address); - return Vtq.New(null, Quality.Bad_CommFailure); - } - finally - { - _readSemaphore.Release(); - } - } - - /// - /// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent). - /// Each tag is read independently. Partial failures return Bad quality for failed tags. - /// - public async Task> ReadBatchAsync( - IEnumerable addresses, CancellationToken ct = default) - { - var addressList = addresses.ToList(); - var results = new Dictionary(addressList.Count, StringComparer.OrdinalIgnoreCase); - - var tasks = addressList.Select(async address => - { - var vtq = await ReadAsync(address, ct); - return (address, vtq); - }); - - foreach (var task in await Task.WhenAll(tasks)) - { - results[task.address] = task.vtq; - } - - return results; - } - - /// - /// Writes a single tag value to MxAccess. - /// Value should be a native .NET type (not string). Uses TypedValueConverter - /// on the gRPC layer; here the value is the boxed .NET object. - /// - public async Task WriteAsync(string address, object value, CancellationToken ct = default) - { - if (!IsConnected) - throw new InvalidOperationException("Not connected to MxAccess"); - - await _writeSemaphore.WaitAsync(ct); - try - { - await _staThread.DispatchAsync(() => WriteInternal(address, value)); - } - finally - { - _writeSemaphore.Release(); - } - } - - /// - /// Writes multiple tag values with semaphore-controlled concurrency. - /// - public async Task WriteBatchAsync( - IReadOnlyDictionary values, CancellationToken ct = default) - { - var tasks = values.Select(async kvp => - { - await WriteAsync(kvp.Key, kvp.Value, ct); - }); - - await Task.WhenAll(tasks); - } - - /// - /// Writes a batch, then polls flagTag until it equals flagValue or timeout expires. - /// Uses type-aware comparison via TypedValueEquals. - /// - public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagTag, - object flagValue, - int timeoutMs, - int pollIntervalMs, - CancellationToken ct = default) - { - // Write all values first - await WriteBatchAsync(values, ct); - - // Poll flag tag - var sw = System.Diagnostics.Stopwatch.StartNew(); - var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000; - var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100; - - while (sw.ElapsedMilliseconds < effectiveTimeout) - { - ct.ThrowIfCancellationRequested(); - - var vtq = await ReadAsync(flagTag, ct); - if (vtq.Quality.IsGood() && TypedValueEquals(vtq.Value, flagValue)) - { - return (true, (int)sw.ElapsedMilliseconds); - } - - await Task.Delay(effectiveInterval, ct); - } - - return (false, (int)sw.ElapsedMilliseconds); - } - - /// - /// Type-aware equality comparison for WriteBatchAndWait flag matching. - /// Both values must be the same CLR type. Mismatched types are never equal. - /// - private static bool TypedValueEquals(object? a, object? b) - { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - if (a.GetType() != b.GetType()) return false; - - // Array types need element-by-element comparison - if (a is Array arrA && b is Array arrB) - { - if (arrA.Length != arrB.Length) return false; - for (int i = 0; i < arrA.Length; i++) - { - if (!Equals(arrA.GetValue(i), arrB.GetValue(i))) - return false; - } - return true; - } - - return Equals(a, b); - } - - // ── Internal COM calls (execute on STA thread) ────────── - - /// - /// Reads a single tag from MxAccess COM API. - /// Must be called on the STA thread. - /// - private Vtq ReadInternal(string address) - { - // This is a skeleton — the exact MxAccess COM API call depends on the - // ArchestrA.MXAccess interop assembly. Consult src-reference for the exact - // method calls. The pattern is: - // - // object value = null; - // int quality = 0; - // DateTime timestamp = DateTime.MinValue; - // _lmxProxy.Read(_connectionHandle, address, ref value, ref quality, ref timestamp); - // - // Then convert the COM value to a Vtq: - // return new Vtq(value, timestamp.ToUniversalTime(), MapQuality(quality)); - // - // For now, this throws NotImplementedException. The actual COM call will be - // implemented when testing on the windev machine with MxAccess available. - - throw new NotImplementedException( - "ReadInternal must be implemented using ArchestrA.MXAccess COM API. " + - "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); - } - - /// - /// Writes a single tag via MxAccess COM API. - /// Must be called on the STA thread. - /// - private void WriteInternal(string address, object value) - { - // Similar to ReadInternal — the exact COM call is: - // _lmxProxy.Write(_connectionHandle, address, value); - // - // Consult src-reference for the exact method signature. - - throw new NotImplementedException( - "WriteInternal must be implemented using ArchestrA.MXAccess COM API. " + - "See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern."); - } - - /// - /// Maps an MxAccess OPC DA quality integer to the domain Quality enum. - /// The quality integer from MxAccess is the OPC DA quality byte. - /// - private static Quality MapQuality(int opcDaQuality) - { - // OPC DA quality is a byte value that directly maps to our Quality enum - if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality)) - return (Quality)(byte)opcDaQuality; - - // Fallback: use category bits - if (opcDaQuality >= 192) return Quality.Good; - if (opcDaQuality >= 64) return Quality.Uncertain; - return Quality.Bad; - } - } -} -``` - -**Important note on ReadInternal/WriteInternal**: These methods contain `throw new NotImplementedException()` because the exact MxAccess COM API signatures depend on the ArchestrA.MXAccess interop assembly, which is only available on the Windows development machine. The implementing session should: - -1. Read `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs` to find the exact COM method signatures. -2. Replace the `throw` with the actual COM calls. -3. The pattern is well-established in the reference code — it's a direct translation, not a redesign. - ---- - -## Step 4: MxAccessClient — Subscriptions - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Subscribes to value changes for the specified addresses. - /// Stores subscription state for reconnect replay. - /// - public async Task SubscribeAsync( - IEnumerable addresses, - Action callback, - CancellationToken ct = default) - { - if (!IsConnected) - throw new InvalidOperationException("Not connected to MxAccess"); - - var addressList = addresses.ToList(); - - await _staThread.DispatchAsync(() => - { - foreach (var address in addressList) - { - SubscribeInternal(address); - - // Store for reconnect replay - lock (_lock) - { - _storedSubscriptions[address] = callback; - } - } - }); - - Log.Information("Subscribed to {Count} tags", addressList.Count); - - return new SubscriptionHandle(this, addressList, callback); - } - - /// - /// Unsubscribes specific addresses. - /// - internal async Task UnsubscribeAsync(IEnumerable addresses) - { - var addressList = addresses.ToList(); - - await _staThread.DispatchAsync(() => - { - foreach (var address in addressList) - { - UnsubscribeInternal(address); - - lock (_lock) - { - _storedSubscriptions.Remove(address); - } - } - }); - - Log.Information("Unsubscribed from {Count} tags", addressList.Count); - } - - /// - /// Recreates all stored subscriptions after a reconnect. - /// Does not re-store them (they're already stored). - /// - private async Task RecreateStoredSubscriptionsAsync() - { - Dictionary> subscriptions; - lock (_lock) - { - if (_storedSubscriptions.Count == 0) return; - subscriptions = new Dictionary>(_storedSubscriptions); - } - - Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count); - - await _staThread.DispatchAsync(() => - { - foreach (var kvp in subscriptions) - { - try - { - SubscribeInternal(kvp.Key); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key); - } - } - }); - } - - // ── Internal COM calls (execute on STA thread) ────────── - - /// - /// Registers a tag subscription with MxAccess COM API (Advise). - /// Must be called on the STA thread. - /// - private void SubscribeInternal(string address) - { - // The exact MxAccess COM API call is something like: - // _lmxProxy.Advise(_connectionHandle, address); - // - // Consult src-reference/Implementation/MxAccessClient.Subscription.cs - - throw new NotImplementedException( - "SubscribeInternal must be implemented using ArchestrA.MXAccess COM API. " + - "See src-reference/Implementation/MxAccessClient.Subscription.cs for the exact pattern."); - } - - /// - /// Unregisters a tag subscription from MxAccess COM API (Unadvise). - /// Must be called on the STA thread. - /// - private void UnsubscribeInternal(string address) - { - // The exact MxAccess COM API call is something like: - // _lmxProxy.Unadvise(_connectionHandle, address); - - throw new NotImplementedException( - "UnsubscribeInternal must be implemented using ArchestrA.MXAccess COM API."); - } - - /// - /// Disposable subscription handle that unsubscribes on disposal. - /// - private sealed class SubscriptionHandle : IAsyncDisposable - { - private readonly MxAccessClient _client; - private readonly List _addresses; - private readonly Action _callback; - private bool _disposed; - - public SubscriptionHandle(MxAccessClient client, List addresses, Action callback) - { - _client = client; - _addresses = addresses; - _callback = callback; - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - await _client.UnsubscribeAsync(_addresses); - } - } - } -} -``` - ---- - -## Step 5: MxAccessClient — Event Handlers - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs` - -```csharp -using System; -using System.Collections.Generic; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Callback invoked by the SubscriptionManager when it needs to deliver - /// data change events. Set by the SubscriptionManager during initialization. - /// - public Action? OnTagValueChanged { get; set; } - - /// - /// COM event handler for MxAccess DataChanged events. - /// Called on the STA thread when a subscribed tag value changes. - /// - private void OnDataChange( - int hConnect, - int numberOfItems, - // The exact parameter types depend on the COM interop assembly. - // Consult src-reference/Implementation/MxAccessClient.EventHandlers.cs - // for the exact signature. The pattern is: - // object[] addresses, object[] values, object[] qualities, object[] timestamps - // or it may use SAFEARRAY parameters. - object addresses, - object values, - object qualities, - object timestamps) - { - // This handler fires on the STA thread. - // Parse the COM arrays and dispatch to OnTagValueChanged for each item. - // - // Skeleton implementation: - try - { - var addrArray = (object[])addresses; - var valArray = (object[])values; - var qualArray = (object[])qualities; - var tsArray = (object[])timestamps; - - for (int i = 0; i < numberOfItems; i++) - { - var address = addrArray[i]?.ToString() ?? ""; - var value = valArray[i]; - var quality = MapQuality(Convert.ToInt32(qualArray[i])); - var timestamp = Convert.ToDateTime(tsArray[i]).ToUniversalTime(); - - var vtq = new Vtq(value, timestamp, quality); - - // Route to stored callback - Action? callback = null; - lock (_lock) - { - _storedSubscriptions.TryGetValue(address, out callback); - } - callback?.Invoke(address, vtq); - - // Also route to the SubscriptionManager's global handler - OnTagValueChanged?.Invoke(address, vtq); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnDataChange event"); - } - } - - /// - /// COM event handler for MxAccess WriteCompleted events. - /// - private void OnWriteComplete( - int hConnect, - int numberOfItems, - object addresses, - object results) - { - // Write completion is currently fire-and-forget. - // Log for diagnostics. - try - { - Log.Debug("WriteCompleted: {Count} items", numberOfItems); - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnWriteComplete event"); - } - } - } -} -``` - -**Important**: The exact COM event handler signatures (`OnDataChange`, `OnWriteComplete`) depend on the ArchestrA.MXAccess COM interop assembly's event definitions. The implementing session MUST consult `src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs` for the exact parameter types. The skeleton above uses a common pattern but may need adjustment. - ---- - -## Step 6: SessionManager - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` - -```csharp -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Sessions -{ - /// - /// Tracks active client sessions in memory. - /// Thread-safe via ConcurrentDictionary. - /// - public sealed class SessionManager : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly ConcurrentDictionary _sessions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly Timer? _scavengingTimer; - private readonly TimeSpan _inactivityTimeout; - - /// - /// Creates a SessionManager with optional inactivity scavenging. - /// - /// - /// Sessions inactive for this many minutes are automatically terminated. - /// Set to 0 to disable scavenging. - /// - public SessionManager(int inactivityTimeoutMinutes = 5) - { - _inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes); - - if (inactivityTimeoutMinutes > 0) - { - // Check every 60 seconds - _scavengingTimer = new Timer(ScavengeInactiveSessions, null, - TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - } - - /// Gets the count of active sessions. - public int ActiveSessionCount => _sessions.Count; - - /// - /// Creates a new session. - /// Returns the 32-character hex GUID session ID. - /// - public string CreateSession(string clientId, string apiKey) - { - var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens - var sessionInfo = new SessionInfo(sessionId, clientId, apiKey); - _sessions[sessionId] = sessionInfo; - - Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId); - return sessionId; - } - - /// - /// Validates a session ID. Updates LastActivity on success. - /// Returns true if the session exists. - /// - public bool ValidateSession(string sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - { - session.TouchLastActivity(); - return true; - } - return false; - } - - /// - /// Terminates a session. Returns true if the session existed. - /// - public bool TerminateSession(string sessionId) - { - if (_sessions.TryRemove(sessionId, out _)) - { - Log.Information("Session terminated: {SessionId}", sessionId); - return true; - } - return false; - } - - /// Gets session info by ID, or null if not found. - public SessionInfo? GetSession(string sessionId) - { - _sessions.TryGetValue(sessionId, out var session); - return session; - } - - /// Gets a snapshot of all active sessions. - public IReadOnlyList GetAllSessions() - { - return _sessions.Values.ToList().AsReadOnly(); - } - - /// - /// Scavenges sessions that have been inactive for longer than the timeout. - /// - private void ScavengeInactiveSessions(object? state) - { - if (_inactivityTimeout <= TimeSpan.Zero) return; - - var cutoff = DateTime.UtcNow - _inactivityTimeout; - var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList(); - - foreach (var kvp in expired) - { - if (_sessions.TryRemove(kvp.Key, out _)) - { - Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", - kvp.Key, kvp.Value.LastActivity); - } - } - } - - public void Dispose() - { - _scavengingTimer?.Dispose(); - _sessions.Clear(); - } - } - - /// - /// Information about an active client session. - /// - public class SessionInfo - { - public SessionInfo(string sessionId, string clientId, string apiKey) - { - SessionId = sessionId; - ClientId = clientId; - ApiKey = apiKey; - ConnectedAt = DateTime.UtcNow; - LastActivity = DateTime.UtcNow; - } - - public string SessionId { get; } - public string ClientId { get; } - public string ApiKey { get; } - public DateTime ConnectedAt { get; } - public DateTime LastActivity { get; private set; } - public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; - - /// Updates the last activity timestamp to now. - public void TouchLastActivity() - { - LastActivity = DateTime.UtcNow; - } - } -} -``` - ---- - -## Step 7: SubscriptionManager - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` - -```csharp -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Channels; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions -{ - /// - /// Manages per-client subscription channels with shared MxAccess subscriptions. - /// Ref-counted tag subscriptions: first client creates, last client disposes. - /// - public sealed class SubscriptionManager : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly int _channelCapacity; - private readonly BoundedChannelFullMode _channelFullMode; - - // Client ID → ClientSubscription - private readonly ConcurrentDictionary _clientSubscriptions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - // Tag address → TagSubscription (shared, ref-counted) - private readonly ConcurrentDictionary _tagSubscriptions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim(); - - public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000, - BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest) - { - _scadaClient = scadaClient; - _channelCapacity = channelCapacity; - _channelFullMode = channelFullMode; - } - - /// - /// Creates a subscription for a client. Returns a ChannelReader to stream from. - /// - public ChannelReader<(string address, Vtq vtq)> Subscribe( - string clientId, IEnumerable addresses, CancellationToken ct) - { - var channel = Channel.CreateBounded<(string address, Vtq vtq)>( - new BoundedChannelOptions(_channelCapacity) - { - FullMode = _channelFullMode, - SingleReader = true, - SingleWriter = false - }); - - var addressSet = new HashSet(addresses, StringComparer.OrdinalIgnoreCase); - var clientSub = new ClientSubscription(clientId, channel, addressSet); - - _clientSubscriptions[clientId] = clientSub; - - _rwLock.EnterWriteLock(); - try - { - foreach (var address in addressSet) - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - tagSub.ClientIds.Add(clientId); - } - else - { - _tagSubscriptions[address] = new TagSubscription(address, - new HashSet(StringComparer.OrdinalIgnoreCase) { clientId }); - } - } - } - finally - { - _rwLock.ExitWriteLock(); - } - - // Register cancellation cleanup - ct.Register(() => UnsubscribeClient(clientId)); - - Log.Information("Client {ClientId} subscribed to {Count} tags", clientId, addressSet.Count); - return channel.Reader; - } - - /// - /// Called from MxAccessClient's OnDataChange handler. - /// Fans out the update to all subscribed clients. - /// - public void OnTagValueChanged(string address, Vtq vtq) - { - _rwLock.EnterReadLock(); - HashSet? clientIds = null; - try - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - clientIds = new HashSet(tagSub.ClientIds); - } - } - finally - { - _rwLock.ExitReadLock(); - } - - if (clientIds == null || clientIds.Count == 0) return; - - foreach (var clientId in clientIds) - { - if (_clientSubscriptions.TryGetValue(clientId, out var clientSub)) - { - if (!clientSub.Channel.Writer.TryWrite((address, vtq))) - { - clientSub.IncrementDropped(); - Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)", - clientId, address); - } - else - { - clientSub.IncrementDelivered(); - } - } - } - } - - /// - /// Removes a client's subscriptions and cleans up tag subscriptions - /// when the last client unsubscribes. - /// - public void UnsubscribeClient(string clientId) - { - if (!_clientSubscriptions.TryRemove(clientId, out var clientSub)) - return; - - _rwLock.EnterWriteLock(); - try - { - foreach (var address in clientSub.Addresses) - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - tagSub.ClientIds.Remove(clientId); - - // Last client unsubscribed — remove the tag subscription - if (tagSub.ClientIds.Count == 0) - { - _tagSubscriptions.TryRemove(address, out _); - } - } - } - } - finally - { - _rwLock.ExitWriteLock(); - } - - // Complete the channel (signals end of stream to the gRPC handler) - clientSub.Channel.Writer.TryComplete(); - - Log.Information("Client {ClientId} unsubscribed ({Delivered} delivered, {Dropped} dropped)", - clientId, clientSub.DeliveredCount, clientSub.DroppedCount); - } - - /// - /// Sends a bad-quality notification to all subscribed clients for all their tags. - /// Called when MxAccess disconnects. - /// - public void NotifyDisconnection() - { - var badVtq = Vtq.New(null, Quality.Bad_NotConnected); - - foreach (var kvp in _clientSubscriptions) - { - foreach (var address in kvp.Value.Addresses) - { - kvp.Value.Channel.Writer.TryWrite((address, badVtq)); - } - } - } - - /// Returns subscription statistics. - public SubscriptionStats GetStats() - { - return new SubscriptionStats( - _clientSubscriptions.Count, - _tagSubscriptions.Count, - _clientSubscriptions.Values.Sum(c => c.Addresses.Count)); - } - - public void Dispose() - { - foreach (var kvp in _clientSubscriptions) - { - kvp.Value.Channel.Writer.TryComplete(); - } - _clientSubscriptions.Clear(); - _tagSubscriptions.Clear(); - _rwLock.Dispose(); - } - - // ── Nested types ───────────────────────────────────────── - - private class ClientSubscription - { - public ClientSubscription(string clientId, - Channel<(string address, Vtq vtq)> channel, - HashSet addresses) - { - ClientId = clientId; - Channel = channel; - Addresses = addresses; - } - - public string ClientId { get; } - public Channel<(string address, Vtq vtq)> Channel { get; } - public HashSet Addresses { get; } - public long DeliveredCount { get; private set; } - public long DroppedCount { get; private set; } - - public void IncrementDelivered() => Interlocked.Increment(ref _delivered); - public void IncrementDropped() => Interlocked.Increment(ref _dropped); - - // Use backing fields for Interlocked - private long _delivered; - private long _dropped; - } - - private class TagSubscription - { - public TagSubscription(string address, HashSet clientIds) - { - Address = address; - ClientIds = clientIds; - } - - public string Address { get; } - public HashSet ClientIds { get; } - } - } -} -``` - -**Note**: The `ClientSubscription` class has a minor issue — `DeliveredCount` and `DroppedCount` properties read the old field values, not the `_delivered`/`_dropped` backing fields. Fix by changing the properties to: - -```csharp -public long DeliveredCount => Interlocked.Read(ref _delivered); -public long DroppedCount => Interlocked.Read(ref _dropped); -``` - -Also need to add `SubscriptionStats` to the Domain: - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// Subscription statistics for monitoring. - public class SubscriptionStats - { - public SubscriptionStats(int totalClients, int totalTags, int activeSubscriptions) - { - TotalClients = totalClients; - TotalTags = totalTags; - ActiveSubscriptions = activeSubscriptions; - } - - public int TotalClients { get; } - public int TotalTags { get; } - public int ActiveSubscriptions { get; } - } -} -``` - ---- - -## Step 8: Unit tests - -### 8.1 SessionManager tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs` - -```csharp -using System; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Sessions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions -{ - public class SessionManagerTests - { - [Fact] - public void CreateSession_Returns32CharHexId() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("client1", "key1"); - id.Should().HaveLength(32); - id.Should().MatchRegex("^[0-9a-f]{32}$"); - } - - [Fact] - public void CreateSession_IncrementsCount() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.ActiveSessionCount.Should().Be(0); - sm.CreateSession("c1", "k1"); - sm.ActiveSessionCount.Should().Be(1); - sm.CreateSession("c2", "k2"); - sm.ActiveSessionCount.Should().Be(2); - } - - [Fact] - public void ValidateSession_ReturnsTrueForExistingSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - sm.ValidateSession(id).Should().BeTrue(); - } - - [Fact] - public void ValidateSession_ReturnsFalseForUnknownSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.ValidateSession("nonexistent").Should().BeFalse(); - } - - [Fact] - public void ValidateSession_UpdatesLastActivity() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - var session = sm.GetSession(id); - var initialActivity = session!.LastActivity; - - Thread.Sleep(50); // Small delay to ensure time passes - sm.ValidateSession(id); - - session.LastActivity.Should().BeAfter(initialActivity); - } - - [Fact] - public void TerminateSession_RemovesSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - sm.TerminateSession(id).Should().BeTrue(); - sm.ActiveSessionCount.Should().Be(0); - sm.ValidateSession(id).Should().BeFalse(); - } - - [Fact] - public void TerminateSession_ReturnsFalseForUnknownSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.TerminateSession("nonexistent").Should().BeFalse(); - } - - [Fact] - public void GetSession_ReturnsNullForUnknown() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.GetSession("nonexistent").Should().BeNull(); - } - - [Fact] - public void GetSession_ReturnsCorrectInfo() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("client-abc", "key-xyz"); - var session = sm.GetSession(id); - session.Should().NotBeNull(); - session!.ClientId.Should().Be("client-abc"); - session.ApiKey.Should().Be("key-xyz"); - session.SessionId.Should().Be(id); - session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - } - - [Fact] - public void GetAllSessions_ReturnsSnapshot() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.CreateSession("c1", "k1"); - sm.CreateSession("c2", "k2"); - var all = sm.GetAllSessions(); - all.Should().HaveCount(2); - } - - [Fact] - public void ConcurrentAccess_IsThreadSafe() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var tasks = new Task[100]; - for (int i = 0; i < 100; i++) - { - int idx = i; - tasks[i] = Task.Run(() => - { - var id = sm.CreateSession($"client-{idx}", $"key-{idx}"); - sm.ValidateSession(id); - if (idx % 3 == 0) sm.TerminateSession(id); - }); - } - Task.WaitAll(tasks); - - // Should have ~67 sessions remaining (100 - ~33 terminated) - sm.ActiveSessionCount.Should().BeInRange(60, 70); - } - - [Fact] - public void Dispose_ClearsAllSessions() - { - var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.CreateSession("c1", "k1"); - sm.CreateSession("c2", "k2"); - sm.Dispose(); - sm.ActiveSessionCount.Should().Be(0); - } - - [Fact] - public void ConnectedSinceUtcTicks_ReturnsCorrectValue() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - var session = sm.GetSession(id); - session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks); - } - } -} -``` - -### 8.2 SubscriptionManager tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions -{ - public class SubscriptionManagerTests - { - /// Fake IScadaClient for testing (no COM dependency). - private class FakeScadaClient : IScadaClient - { - public bool IsConnected => true; - public ConnectionState ConnectionState => ConnectionState.Connected; - public event EventHandler? ConnectionStateChanged; - public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task ReadAsync(string address, CancellationToken ct = default) => - Task.FromResult(Vtq.Good(42.0)); - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => - Task.FromResult>(new Dictionary()); - public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; - public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; - public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, string flagTag, object flagValue, - int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => - Task.FromResult((false, 0)); - public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => - Task.FromResult(new FakeSubscriptionHandle()); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - private class FakeSubscriptionHandle : IAsyncDisposable { public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - } - - [Fact] - public void Subscribe_ReturnsChannelReader() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Tag1", "Tag2" }, cts.Token); - reader.Should().NotBeNull(); - } - - [Fact] - public async Task OnTagValueChanged_FansOutToSubscribedClients() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - - var vtq = Vtq.Good(42.0); - sm.OnTagValueChanged("Motor.Speed", vtq); - - var result = await reader.ReadAsync(cts.Token); - result.address.Should().Be("Motor.Speed"); - result.vtq.Value.Should().Be(42.0); - result.vtq.Quality.Should().Be(Quality.Good); - } - - [Fact] - public async Task OnTagValueChanged_MultipleClients_BothReceive() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader1 = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - var reader2 = sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); - - sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0)); - - var r1 = await reader1.ReadAsync(cts.Token); - var r2 = await reader2.ReadAsync(cts.Token); - r1.vtq.Value.Should().Be(99.0); - r2.vtq.Value.Should().Be(99.0); - } - - [Fact] - public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0)); - - // Channel should be empty - reader.TryRead(out _).Should().BeFalse(); - } - - [Fact] - public void UnsubscribeClient_CompletesChannel() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.UnsubscribeClient("client1"); - - // Channel should be completed - reader.Completion.IsCompleted.Should().BeTrue(); - } - - [Fact] - public void UnsubscribeClient_RemovesFromTagSubscriptions() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.UnsubscribeClient("client1"); - - var stats = sm.GetStats(); - stats.TotalClients.Should().Be(0); - stats.TotalTags.Should().Be(0); - } - - [Fact] - public void RefCounting_LastClientUnsubscribeRemovesTag() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token); - - sm.GetStats().TotalTags.Should().Be(1); - - sm.UnsubscribeClient("client1"); - sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed - - sm.UnsubscribeClient("client2"); - sm.GetStats().TotalTags.Should().Be(0); // last client gone - } - - [Fact] - public void NotifyDisconnection_SendsBadQualityToAll() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token); - - sm.NotifyDisconnection(); - - // Should receive 2 bad quality messages - reader.TryRead(out var r1).Should().BeTrue(); - r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected); - reader.TryRead(out var r2).Should().BeTrue(); - r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected); - } - - [Fact] - public void Backpressure_DropOldest_DropsWhenFull() - { - using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3); - using var cts = new CancellationTokenSource(); - var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token); - - // Fill the channel beyond capacity - for (int i = 0; i < 10; i++) - { - sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i)); - } - - // Should have exactly 3 messages (capacity limit) - int count = 0; - while (reader.TryRead(out _)) count++; - count.Should().Be(3); - } - - [Fact] - public void GetStats_ReturnsCorrectCounts() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - sm.Subscribe("c1", new[] { "Tag1", "Tag2" }, cts.Token); - sm.Subscribe("c2", new[] { "Tag2", "Tag3" }, cts.Token); - - var stats = sm.GetStats(); - stats.TotalClients.Should().Be(2); - stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3 - stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3 - } - } -} -``` - -### 8.3 StaDispatchThread tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/StaDispatchThreadTests.cs` - -```csharp -using System; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.MxAccess; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess -{ - public class StaDispatchThreadTests - { - [Fact] - public async Task DispatchAsync_ExecutesOnStaThread() - { - using var sta = new StaDispatchThread("Test-STA"); - var threadId = await sta.DispatchAsync(() => Thread.CurrentThread.ManagedThreadId); - threadId.Should().NotBe(Thread.CurrentThread.ManagedThreadId); - } - - [Fact] - public async Task DispatchAsync_ReturnsResult() - { - using var sta = new StaDispatchThread("Test-STA"); - var result = await sta.DispatchAsync(() => 42); - result.Should().Be(42); - } - - [Fact] - public async Task DispatchAsync_PropagatesException() - { - using var sta = new StaDispatchThread("Test-STA"); - var act = () => sta.DispatchAsync(() => throw new InvalidOperationException("test error")); - await act.Should().ThrowAsync().WithMessage("test error"); - } - - [Fact] - public async Task DispatchAsync_Action_Completes() - { - using var sta = new StaDispatchThread("Test-STA"); - int value = 0; - await sta.DispatchAsync(() => { value = 99; }); - value.Should().Be(99); - } - - [Fact] - public void Dispose_CompletesGracefully() - { - var sta = new StaDispatchThread("Test-STA"); - sta.Dispose(); // Should not throw - } - - [Fact] - public void DispatchAfterDispose_ThrowsObjectDisposedException() - { - var sta = new StaDispatchThread("Test-STA"); - sta.Dispose(); - var act = () => sta.DispatchAsync(() => 42); - act.Should().ThrowAsync(); - } - - [Fact] - public async Task MultipleDispatches_ExecuteInOrder() - { - using var sta = new StaDispatchThread("Test-STA"); - var results = new System.Collections.Concurrent.ConcurrentBag(); - - var tasks = new Task[10]; - for (int i = 0; i < 10; i++) - { - int idx = i; - tasks[i] = sta.DispatchAsync(() => { results.Add(idx); }); - } - - await Task.WhenAll(tasks); - results.Count.Should().Be(10); - } - - [Fact] - public async Task StaThread_HasStaApartmentState() - { - using var sta = new StaDispatchThread("Test-STA"); - var apartmentState = await sta.DispatchAsync(() => Thread.CurrentThread.GetApartmentState()); - apartmentState.Should().Be(ApartmentState.STA); - } - } -} -``` - -### 8.4 MxAccessClient TypedValueEquals tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs` - -Since `TypedValueEquals` is private in `MxAccessClient`, test it indirectly or extract it to a helper. For testability, create a public static helper: - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs` - -```csharp -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Type-aware equality comparison for WriteBatchAndWait flag matching. - /// - public static class TypedValueComparer - { - /// - /// Returns true if both values are the same type and equal. - /// Mismatched types are never equal. - /// Null equals null only. - /// - public static bool Equals(object? a, object? b) - { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - if (a.GetType() != b.GetType()) return false; - - if (a is Array arrA && b is Array arrB) - { - if (arrA.Length != arrB.Length) return false; - for (int i = 0; i < arrA.Length; i++) - { - if (!object.Equals(arrA.GetValue(i), arrB.GetValue(i))) - return false; - } - return true; - } - - return object.Equals(a, b); - } - } -} -``` - -Then the test file: - -```csharp -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess -{ - public class TypedValueEqualsTests - { - [Fact] - public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue(); - - [Fact] - public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse(); - - [Fact] - public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse(); - - [Fact] - public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue(); - - [Fact] - public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse(); - - [Fact] - public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); - - [Fact] - public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue(); - - [Fact] - public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse(); - - [Fact] - public void String_CaseSensitive() - { - TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue(); - TypedValueComparer.Equals("done", "DONE").Should().BeFalse(); - } - - [Fact] - public void Array_SameElements() - { - TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue(); - } - - [Fact] - public void Array_DifferentElements() - { - TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse(); - } - - [Fact] - public void Array_DifferentLengths() - { - TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse(); - } - - [Fact] - public void Int32_NotEqual_ToDouble() - { - TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); - } - - [Fact] - public void Long_Equality() - { - TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue(); - } - - [Fact] - public void DateTime_TickPrecision() - { - var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); - var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); - TypedValueComparer.Equals(dt1, dt2).Should().BeTrue(); - } - } -} -``` - ---- - -## Step 9: Build verification - -```bash -cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy - -# Build Client (works on macOS) -dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj - -# Run Client tests -dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj - -# Host builds on Windows only (net48/x86): -# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj -# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj -``` - ---- - -## Completion Criteria - -- [ ] `StaDispatchThread` compiles and tests pass (STA apartment, dispatch, exception propagation) -- [ ] `MxAccessClient` main class compiles with all partial files -- [ ] `MxAccessClient.Connection.cs` compiles (ConnectAsync, DisconnectAsync, MonitorConnectionAsync) -- [ ] `MxAccessClient.ReadWrite.cs` compiles (ReadAsync, ReadBatchAsync, WriteAsync, WriteBatchAsync, WriteBatchAndWaitAsync) -- [ ] `MxAccessClient.Subscription.cs` compiles (SubscribeAsync, UnsubscribeAsync, RecreateStoredSubscriptionsAsync) -- [ ] `MxAccessClient.EventHandlers.cs` compiles (OnDataChange, OnWriteComplete) -- [ ] `SessionManager` compiles and all tests pass (CRUD, scavenging, concurrency) -- [ ] `SubscriptionManager` compiles and all tests pass (subscribe, fan-out, unsubscribe, ref-counting, backpressure, disconnect notification) -- [ ] `TypedValueComparer` tests pass (all comparison rules from design doc) -- [ ] COM method bodies are marked with `NotImplementedException` and clear instructions to consult reference code -- [ ] No references to old namespaces diff --git a/deprecated/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md b/deprecated/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md deleted file mode 100644 index 7b25f97..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-3-host-grpc-security-config.md +++ /dev/null @@ -1,1799 +0,0 @@ -# Phase 3: Host gRPC Server, Security & Configuration — Implementation Plan - -## Prerequisites - -- Phase 1 complete: proto file, domain types, TypedValueConverter, QualityCodeMapper, cross-stack tests passing. -- Phase 2 complete: MxAccessClient, SessionManager, SubscriptionManager, StaDispatchThread compiling and tests passing. -- The following Phase 2 artifacts are used in this phase: - - `src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` — `IScadaClient` implementation - - `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` - - `src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` - -## Guardrails - -1. **Proto is the source of truth** — all RPC implementations match `scada.proto` exactly. -2. **No v1 code** — no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons. -3. **status_code is canonical** — use `QualityCodeMapper` factory methods for all quality responses. -4. **x-api-key header is authoritative** — interceptor enforces, `ConnectRequest.api_key` is informational only. -5. **TypedValueConverter for all COM↔proto conversions** — no manual type switching in the gRPC service. -6. **Unit tests for every component** before marking phase complete. - ---- - -## Step 1: Configuration classes - -All configuration classes go in `src/ZB.MOM.WW.LmxProxy.Host/Configuration/`. - -### 1.1 LmxProxyConfiguration (root) - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Root configuration class bound to appsettings.json. - public class LmxProxyConfiguration - { - /// gRPC server listen port. Default: 50051. - public int GrpcPort { get; set; } = 50051; - - /// Path to API key configuration file. Default: apikeys.json. - public string ApiKeyConfigFile { get; set; } = "apikeys.json"; - - /// MxAccess connection settings. - public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration(); - - /// Subscription channel settings. - public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration(); - - /// TLS/SSL settings. - public TlsConfiguration Tls { get; set; } = new TlsConfiguration(); - - /// Status web server settings. - public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration(); - - /// Windows SCM service recovery settings. - public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration(); - } -} -``` - -### 1.2 ConnectionConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// MxAccess connection settings. - public class ConnectionConfiguration - { - /// Auto-reconnect check interval in seconds. Default: 5. - public int MonitorIntervalSeconds { get; set; } = 5; - - /// Initial connection timeout in seconds. Default: 30. - public int ConnectionTimeoutSeconds { get; set; } = 30; - - /// Per-read operation timeout in seconds. Default: 5. - public int ReadTimeoutSeconds { get; set; } = 5; - - /// Per-write operation timeout in seconds. Default: 5. - public int WriteTimeoutSeconds { get; set; } = 5; - - /// Semaphore limit for concurrent MxAccess operations. Default: 10. - public int MaxConcurrentOperations { get; set; } = 10; - - /// Enable auto-reconnect loop. Default: true. - public bool AutoReconnect { get; set; } = true; - - /// MxAccess node name (optional). - public string? NodeName { get; set; } - - /// MxAccess galaxy name (optional). - public string? GalaxyName { get; set; } - } -} -``` - -### 1.3 SubscriptionConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Subscription channel settings. - public class SubscriptionConfiguration - { - /// Per-client subscription buffer size. Default: 1000. - public int ChannelCapacity { get; set; } = 1000; - - /// Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest. - public string ChannelFullMode { get; set; } = "DropOldest"; - } -} -``` - -### 1.4 TlsConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// TLS/SSL settings for the gRPC server. - public class TlsConfiguration - { - /// Enable TLS on the gRPC server. Default: false. - public bool Enabled { get; set; } = false; - - /// PEM server certificate path. Default: certs/server.crt. - public string ServerCertificatePath { get; set; } = "certs/server.crt"; - - /// PEM server private key path. Default: certs/server.key. - public string ServerKeyPath { get; set; } = "certs/server.key"; - - /// CA certificate for mutual TLS client validation. Default: certs/ca.crt. - public string ClientCaCertificatePath { get; set; } = "certs/ca.crt"; - - /// Require client certificates (mutual TLS). Default: false. - public bool RequireClientCertificate { get; set; } = false; - - /// Check certificate revocation lists. Default: false. - public bool CheckCertificateRevocation { get; set; } = false; - } -} -``` - -### 1.5 WebServerConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// HTTP status web server settings. - public class WebServerConfiguration - { - /// Enable the status web server. Default: true. - public bool Enabled { get; set; } = true; - - /// HTTP listen port. Default: 8080. - public int Port { get; set; } = 8080; - - /// Custom URL prefix (defaults to http://+:{Port}/ if null). - public string? Prefix { get; set; } - } -} -``` - -### 1.6 ServiceRecoveryConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Windows SCM service recovery settings. - public class ServiceRecoveryConfiguration - { - /// Restart delay after first failure in minutes. Default: 1. - public int FirstFailureDelayMinutes { get; set; } = 1; - - /// Restart delay after second failure in minutes. Default: 5. - public int SecondFailureDelayMinutes { get; set; } = 5; - - /// Restart delay after subsequent failures in minutes. Default: 10. - public int SubsequentFailureDelayMinutes { get; set; } = 10; - - /// Days before failure count resets. Default: 1. - public int ResetPeriodDays { get; set; } = 1; - } -} -``` - ---- - -## Step 2: ConfigurationValidator - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.IO; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Validates the LmxProxy configuration at startup. - /// Throws InvalidOperationException on any validation error. - /// - public static class ConfigurationValidator - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); - - /// - /// Validates all configuration settings and logs the effective values. - /// Throws on first validation error. - /// - public static void ValidateAndLog(LmxProxyConfiguration config) - { - var errors = new List(); - - // GrpcPort - if (config.GrpcPort < 1 || config.GrpcPort > 65535) - errors.Add($"GrpcPort must be 1-65535, got {config.GrpcPort}"); - - // Connection - var conn = config.Connection; - if (conn.MonitorIntervalSeconds <= 0) - errors.Add($"Connection.MonitorIntervalSeconds must be > 0, got {conn.MonitorIntervalSeconds}"); - if (conn.ConnectionTimeoutSeconds <= 0) - errors.Add($"Connection.ConnectionTimeoutSeconds must be > 0, got {conn.ConnectionTimeoutSeconds}"); - if (conn.ReadTimeoutSeconds <= 0) - errors.Add($"Connection.ReadTimeoutSeconds must be > 0, got {conn.ReadTimeoutSeconds}"); - if (conn.WriteTimeoutSeconds <= 0) - errors.Add($"Connection.WriteTimeoutSeconds must be > 0, got {conn.WriteTimeoutSeconds}"); - if (conn.MaxConcurrentOperations <= 0) - errors.Add($"Connection.MaxConcurrentOperations must be > 0, got {conn.MaxConcurrentOperations}"); - if (conn.NodeName != null && conn.NodeName.Length > 255) - errors.Add("Connection.NodeName must be <= 255 characters"); - if (conn.GalaxyName != null && conn.GalaxyName.Length > 255) - errors.Add("Connection.GalaxyName must be <= 255 characters"); - - // Subscription - var sub = config.Subscription; - if (sub.ChannelCapacity < 0 || sub.ChannelCapacity > 100000) - errors.Add($"Subscription.ChannelCapacity must be 0-100000, got {sub.ChannelCapacity}"); - var validModes = new HashSet(StringComparer.OrdinalIgnoreCase) - { "DropOldest", "DropNewest", "Wait" }; - if (!validModes.Contains(sub.ChannelFullMode)) - errors.Add($"Subscription.ChannelFullMode must be DropOldest, DropNewest, or Wait, got '{sub.ChannelFullMode}'"); - - // ServiceRecovery - var sr = config.ServiceRecovery; - if (sr.FirstFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.FirstFailureDelayMinutes must be >= 0, got {sr.FirstFailureDelayMinutes}"); - if (sr.SecondFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.SecondFailureDelayMinutes must be >= 0, got {sr.SecondFailureDelayMinutes}"); - if (sr.SubsequentFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.SubsequentFailureDelayMinutes must be >= 0, got {sr.SubsequentFailureDelayMinutes}"); - if (sr.ResetPeriodDays <= 0) - errors.Add($"ServiceRecovery.ResetPeriodDays must be > 0, got {sr.ResetPeriodDays}"); - - // TLS - if (config.Tls.Enabled) - { - if (!File.Exists(config.Tls.ServerCertificatePath)) - Log.Warning("TLS enabled but server certificate not found at {Path} (will auto-generate)", - config.Tls.ServerCertificatePath); - if (!File.Exists(config.Tls.ServerKeyPath)) - Log.Warning("TLS enabled but server key not found at {Path} (will auto-generate)", - config.Tls.ServerKeyPath); - } - - // WebServer - if (config.WebServer.Enabled) - { - if (config.WebServer.Port < 1 || config.WebServer.Port > 65535) - errors.Add($"WebServer.Port must be 1-65535, got {config.WebServer.Port}"); - } - - if (errors.Count > 0) - { - foreach (var error in errors) - Log.Error("Configuration error: {Error}", error); - throw new InvalidOperationException( - $"Configuration validation failed with {errors.Count} error(s): {string.Join("; ", errors)}"); - } - - // Log effective configuration - Log.Information("Configuration validated successfully"); - Log.Information(" GrpcPort: {Port}", config.GrpcPort); - Log.Information(" ApiKeyConfigFile: {File}", config.ApiKeyConfigFile); - Log.Information(" Connection.AutoReconnect: {AutoReconnect}", conn.AutoReconnect); - Log.Information(" Connection.MonitorIntervalSeconds: {Interval}", conn.MonitorIntervalSeconds); - Log.Information(" Connection.MaxConcurrentOperations: {Max}", conn.MaxConcurrentOperations); - Log.Information(" Subscription.ChannelCapacity: {Capacity}", sub.ChannelCapacity); - Log.Information(" Subscription.ChannelFullMode: {Mode}", sub.ChannelFullMode); - Log.Information(" Tls.Enabled: {Enabled}", config.Tls.Enabled); - Log.Information(" WebServer.Enabled: {Enabled}, Port: {Port}", config.WebServer.Enabled, config.WebServer.Port); - } - } -} -``` - ---- - -## Step 3: ApiKey model and ApiKeyService - -### 3.1 ApiKey model - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// An API key with description, role, and enabled state. - public class ApiKey - { - public string Key { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly; - public bool Enabled { get; set; } = true; - } - - /// API key role for authorization. - public enum ApiKeyRole - { - /// Read and subscribe only. - ReadOnly, - /// Full access including writes. - ReadWrite - } -} -``` - -### 3.2 ApiKeyConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs` - -```csharp -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// JSON structure for the API key configuration file. - public class ApiKeyConfiguration - { - public List ApiKeys { get; set; } = new List(); - } -} -``` - -### 3.3 ApiKeyService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using Newtonsoft.Json; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher. - /// - public sealed class ApiKeyService : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly string _configFilePath; - private readonly FileSystemWatcher? _watcher; - private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1); - private volatile Dictionary _keys = new Dictionary(StringComparer.Ordinal); - private DateTime _lastReloadTime = DateTime.MinValue; - private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1); - - public ApiKeyService(string configFilePath) - { - _configFilePath = Path.GetFullPath(configFilePath); - - // Auto-generate default file if missing - if (!File.Exists(_configFilePath)) - { - GenerateDefaultKeyFile(); - } - - // Initial load - LoadKeys(); - - // Set up FileSystemWatcher for hot-reload - var directory = Path.GetDirectoryName(_configFilePath); - var fileName = Path.GetFileName(_configFilePath); - if (directory != null) - { - _watcher = new FileSystemWatcher(directory, fileName) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size, - EnableRaisingEvents = true - }; - _watcher.Changed += OnFileChanged; - } - } - - /// - /// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise. - /// - public ApiKey? ValidateApiKey(string apiKey) - { - if (string.IsNullOrEmpty(apiKey)) return null; - return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null; - } - - /// - /// Checks if a key has the required role. - /// ReadWrite implies ReadOnly. - /// - public bool HasRole(string apiKey, ApiKeyRole requiredRole) - { - var key = ValidateApiKey(apiKey); - if (key == null) return false; - - return requiredRole switch - { - ApiKeyRole.ReadOnly => true, // Both roles have ReadOnly - ApiKeyRole.ReadWrite => key.Role == ApiKeyRole.ReadWrite, - _ => false - }; - } - - /// Gets the count of loaded API keys. - public int KeyCount => _keys.Count; - - private void GenerateDefaultKeyFile() - { - Log.Information("API key file not found at {Path}, generating defaults", _configFilePath); - - var config = new ApiKeyConfiguration - { - ApiKeys = new List - { - new ApiKey - { - Key = GenerateRandomKey(), - Description = "Default ReadOnly key (auto-generated)", - Role = ApiKeyRole.ReadOnly, - Enabled = true - }, - new ApiKey - { - Key = GenerateRandomKey(), - Description = "Default ReadWrite key (auto-generated)", - Role = ApiKeyRole.ReadWrite, - Enabled = true - } - } - }; - - var directory = Path.GetDirectoryName(_configFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); - - var json = JsonConvert.SerializeObject(config, Formatting.Indented); - File.WriteAllText(_configFilePath, json); - Log.Information("Default API key file generated at {Path}", _configFilePath); - } - - private static string GenerateRandomKey() - { - // 32 random bytes → 64-char hex string - var bytes = new byte[32]; - using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) - { - rng.GetBytes(bytes); - } - return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); - } - - private void LoadKeys() - { - try - { - var json = File.ReadAllText(_configFilePath); - var config = JsonConvert.DeserializeObject(json); - if (config?.ApiKeys != null) - { - _keys = config.ApiKeys - .Where(k => !string.IsNullOrEmpty(k.Key)) - .ToDictionary(k => k.Key, k => k, StringComparer.Ordinal); - Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath); - } - else - { - Log.Warning("API key file at {Path} contained no keys", _configFilePath); - _keys = new Dictionary(StringComparer.Ordinal); - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath); - } - } - - private void OnFileChanged(object sender, FileSystemEventArgs e) - { - // Debounce: ignore rapid changes within 1 second - if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return; - - if (_reloadLock.Wait(0)) - { - try - { - _lastReloadTime = DateTime.UtcNow; - Log.Information("API key file changed, reloading"); - - // Small delay to let the file system finish writing - Thread.Sleep(100); - LoadKeys(); - } - finally - { - _reloadLock.Release(); - } - } - } - - public void Dispose() - { - _watcher?.Dispose(); - _reloadLock.Dispose(); - } - } -} -``` - ---- - -## Step 4: ApiKeyInterceptor - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs` - -```csharp -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// gRPC server interceptor that enforces API key authentication and role-based authorization. - /// Extracts x-api-key from metadata, validates via ApiKeyService, enforces ReadWrite for writes. - /// - public class ApiKeyInterceptor : Interceptor - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly ApiKeyService _apiKeyService; - - /// RPC method names that require the ReadWrite role. - private static readonly HashSet WriteProtectedMethods = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - - public ApiKeyInterceptor(ApiKeyService apiKeyService) - { - _apiKeyService = apiKeyService; - } - - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - ValidateApiKey(context); - return await continuation(request, context); - } - - public override async Task ServerStreamingServerHandler( - TRequest request, - IServerStreamWriter responseStream, - ServerCallContext context, - ServerStreamingServerMethod continuation) - { - ValidateApiKey(context); - await continuation(request, responseStream, context); - } - - private void ValidateApiKey(ServerCallContext context) - { - // Extract x-api-key from metadata - var apiKeyEntry = context.RequestHeaders.Get("x-api-key"); - var apiKey = apiKeyEntry?.Value; - - if (string.IsNullOrEmpty(apiKey)) - { - Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method); - throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing x-api-key header")); - } - - var key = _apiKeyService.ValidateApiKey(apiKey); - if (key == null) - { - Log.Warning("Request rejected: invalid API key for {Method}", context.Method); - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key")); - } - - // Check write authorization - if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite) - { - Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method); - throw new RpcException(new Status(StatusCode.PermissionDenied, - "Write operations require a ReadWrite API key")); - } - - // Store the validated key in UserState for downstream use - context.UserState["ApiKey"] = key; - } - } -} -``` - ---- - -## Step 5: TlsCertificateManager - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs` - -```csharp -using System.IO; -using Grpc.Core; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Manages TLS certificates for the gRPC server. - /// If TLS is enabled but certs are missing, logs a warning (self-signed generation - /// would be added as a future enhancement, or done manually). - /// - public static class TlsCertificateManager - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager)); - - /// - /// Creates gRPC server credentials based on TLS configuration. - /// Returns InsecureServerCredentials if TLS is disabled. - /// - public static ServerCredentials CreateServerCredentials(TlsConfiguration config) - { - if (!config.Enabled) - { - Log.Information("TLS disabled, using insecure server credentials"); - return ServerCredentials.Insecure; - } - - if (!File.Exists(config.ServerCertificatePath) || !File.Exists(config.ServerKeyPath)) - { - Log.Warning("TLS enabled but certificate files not found. Falling back to insecure credentials. " + - "Cert: {CertPath}, Key: {KeyPath}", - config.ServerCertificatePath, config.ServerKeyPath); - return ServerCredentials.Insecure; - } - - var certChain = File.ReadAllText(config.ServerCertificatePath); - var privateKey = File.ReadAllText(config.ServerKeyPath); - - var keyCertPair = new KeyCertificatePair(certChain, privateKey); - - if (config.RequireClientCertificate && File.Exists(config.ClientCaCertificatePath)) - { - var caCert = File.ReadAllText(config.ClientCaCertificatePath); - Log.Information("TLS enabled with mutual TLS (client certificate required)"); - return new SslServerCredentials( - new[] { keyCertPair }, - caCert, - SslClientCertificateRequestType.RequestAndRequireAndVerify); - } - - Log.Information("TLS enabled (server-only)"); - return new SslServerCredentials(new[] { keyCertPair }); - } - } -} -``` - ---- - -## Step 6: ScadaGrpcService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs` - -This file implements all 10 RPCs. It inherits from the proto-generated `Scada.ScadaService.ScadaServiceBase` base class. The proto codegen produces this base class from the `service ScadaService { ... }` in `scada.proto`. - -```csharp -using System; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using Grpc.Core; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Sessions; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services -{ - /// - /// gRPC service implementation for all 10 SCADA RPCs. - /// Inherits from proto-generated ScadaService.ScadaServiceBase. - /// - public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly SessionManager _sessionManager; - private readonly SubscriptionManager _subscriptionManager; - - public ScadaGrpcService( - IScadaClient scadaClient, - SessionManager sessionManager, - SubscriptionManager subscriptionManager) - { - _scadaClient = scadaClient; - _sessionManager = sessionManager; - _subscriptionManager = subscriptionManager; - } - - // ── Connection Management ───────────────────────────────── - - public override Task Connect( - Scada.ConnectRequest request, ServerCallContext context) - { - try - { - if (!_scadaClient.IsConnected) - { - return Task.FromResult(new Scada.ConnectResponse - { - Success = false, - Message = "MxAccess is not connected" - }); - } - - var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey); - - return Task.FromResult(new Scada.ConnectResponse - { - Success = true, - Message = "Connected", - SessionId = sessionId - }); - } - catch (Exception ex) - { - Log.Error(ex, "Connect failed for client {ClientId}", request.ClientId); - return Task.FromResult(new Scada.ConnectResponse - { - Success = false, - Message = ex.Message - }); - } - } - - public override Task Disconnect( - Scada.DisconnectRequest request, ServerCallContext context) - { - try - { - // Clean up subscriptions for this session - _subscriptionManager.UnsubscribeClient(request.SessionId); - - var terminated = _sessionManager.TerminateSession(request.SessionId); - return Task.FromResult(new Scada.DisconnectResponse - { - Success = terminated, - Message = terminated ? "Disconnected" : "Session not found" - }); - } - catch (Exception ex) - { - Log.Error(ex, "Disconnect failed for session {SessionId}", request.SessionId); - return Task.FromResult(new Scada.DisconnectResponse - { - Success = false, - Message = ex.Message - }); - } - } - - public override Task GetConnectionState( - Scada.GetConnectionStateRequest request, ServerCallContext context) - { - var session = _sessionManager.GetSession(request.SessionId); - return Task.FromResult(new Scada.GetConnectionStateResponse - { - IsConnected = _scadaClient.IsConnected, - ClientId = session?.ClientId ?? "", - ConnectedSinceUtcTicks = session?.ConnectedSinceUtcTicks ?? 0 - }); - } - - // ── Read Operations ──────────────────────────────────────── - - public override async Task Read( - Scada.ReadRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.ReadResponse - { - Success = false, - Message = "Invalid session", - Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.Bad()) - }; - } - - try - { - var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken); - return new Scada.ReadResponse - { - Success = true, - Message = "", - Vtq = ConvertToProtoVtq(request.Tag, vtq) - }; - } - catch (Exception ex) - { - Log.Error(ex, "Read failed for tag {Tag}", request.Tag); - return new Scada.ReadResponse - { - Success = false, - Message = ex.Message, - Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.BadCommunicationFailure()) - }; - } - } - - public override async Task ReadBatch( - Scada.ReadBatchRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.ReadBatchResponse - { - Success = false, - Message = "Invalid session" - }; - } - - try - { - var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken); - - var response = new Scada.ReadBatchResponse - { - Success = true, - Message = "" - }; - - // Return results in request order - foreach (var tag in request.Tags) - { - if (results.TryGetValue(tag, out var vtq)) - { - response.Vtqs.Add(ConvertToProtoVtq(tag, vtq)); - } - else - { - response.Vtqs.Add(CreateBadVtq(tag, QualityCodeMapper.BadConfigurationError())); - } - } - - return response; - } - catch (Exception ex) - { - Log.Error(ex, "ReadBatch failed"); - return new Scada.ReadBatchResponse - { - Success = false, - Message = ex.Message - }; - } - } - - // ── Write Operations ─────────────────────────────────────── - - public override async Task Write( - Scada.WriteRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteResponse { Success = false, Message = "Invalid session" }; - } - - try - { - var value = TypedValueConverter.FromTypedValue(request.Value); - await _scadaClient.WriteAsync(request.Tag, value!, context.CancellationToken); - return new Scada.WriteResponse { Success = true, Message = "" }; - } - catch (Exception ex) - { - Log.Error(ex, "Write failed for tag {Tag}", request.Tag); - return new Scada.WriteResponse { Success = false, Message = ex.Message }; - } - } - - public override async Task WriteBatch( - Scada.WriteBatchRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" }; - } - - var response = new Scada.WriteBatchResponse { Success = true, Message = "" }; - - foreach (var item in request.Items) - { - try - { - var value = TypedValueConverter.FromTypedValue(item.Value); - await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); - response.Results.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = true, Message = "" - }); - } - catch (Exception ex) - { - response.Success = false; - response.Results.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = false, Message = ex.Message - }); - } - } - - return response; - } - - public override async Task WriteBatchAndWait( - Scada.WriteBatchAndWaitRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteBatchAndWaitResponse { Success = false, Message = "Invalid session" }; - } - - var response = new Scada.WriteBatchAndWaitResponse { Success = true }; - - // Write all items first - var values = request.Items.ToDictionary( - i => i.Tag, - i => TypedValueConverter.FromTypedValue(i.Value)!); - - try - { - // Execute writes and collect results - foreach (var item in request.Items) - { - try - { - var value = TypedValueConverter.FromTypedValue(item.Value); - await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); - response.WriteResults.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = true, Message = "" - }); - } - catch (Exception ex) - { - response.Success = false; - response.Message = "One or more writes failed"; - response.WriteResults.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = false, Message = ex.Message - }); - } - } - - // If any write failed, return immediately - if (!response.Success) - return response; - - // Poll flag tag - var flagValue = TypedValueConverter.FromTypedValue(request.FlagValue); - var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000; - var pollIntervalMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; - - var sw = Stopwatch.StartNew(); - while (sw.ElapsedMilliseconds < timeoutMs) - { - context.CancellationToken.ThrowIfCancellationRequested(); - - var vtq = await _scadaClient.ReadAsync(request.FlagTag, context.CancellationToken); - if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue)) - { - response.FlagReached = true; - response.ElapsedMs = (int)sw.ElapsedMilliseconds; - return response; - } - - await Task.Delay(pollIntervalMs, context.CancellationToken); - } - - // Timeout — not an error - response.FlagReached = false; - response.ElapsedMs = (int)sw.ElapsedMilliseconds; - return response; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Log.Error(ex, "WriteBatchAndWait failed"); - return new Scada.WriteBatchAndWaitResponse - { - Success = false, Message = ex.Message - }; - } - } - - // ── Subscription ─────────────────────────────────────────── - - public override async Task Subscribe( - Scada.SubscribeRequest request, - IServerStreamWriter responseStream, - ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session")); - } - - var reader = _subscriptionManager.Subscribe( - request.SessionId, request.Tags, context.CancellationToken); - - try - { - while (await reader.WaitToReadAsync(context.CancellationToken)) - { - while (reader.TryRead(out var item)) - { - var protoVtq = ConvertToProtoVtq(item.address, item.vtq); - await responseStream.WriteAsync(protoVtq); - } - } - } - catch (OperationCanceledException) - { - // Client disconnected — normal - } - catch (Exception ex) - { - Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId); - throw new RpcException(new Status(StatusCode.Internal, ex.Message)); - } - finally - { - _subscriptionManager.UnsubscribeClient(request.SessionId); - } - } - - // ── API Key Check ────────────────────────────────────────── - - public override Task CheckApiKey( - Scada.CheckApiKeyRequest request, ServerCallContext context) - { - // The interceptor already validated the x-api-key header. - // This RPC lets clients explicitly check a specific key. - // The validated key from the interceptor is in context.UserState. - var isValid = context.UserState.ContainsKey("ApiKey"); - return Task.FromResult(new Scada.CheckApiKeyResponse - { - IsValid = isValid, - Message = isValid ? "Valid" : "Invalid" - }); - } - - // ── Helpers ──────────────────────────────────────────────── - - /// Converts a domain Vtq to a proto VtqMessage. - private static Scada.VtqMessage ConvertToProtoVtq(string tag, Vtq vtq) - { - return new Scada.VtqMessage - { - Tag = tag, - Value = TypedValueConverter.ToTypedValue(vtq.Value), - TimestampUtcTicks = vtq.Timestamp.Ticks, - Quality = QualityCodeMapper.ToQualityCode(vtq.Quality) - }; - } - - /// Creates a VtqMessage with bad quality for error responses. - private static Scada.VtqMessage CreateBadVtq(string tag, Scada.QualityCode quality) - { - return new Scada.VtqMessage - { - Tag = tag, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = quality - }; - } - } -} -``` - ---- - -## Step 7: LmxProxyService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` - -```csharp -using System; -using System.Threading; -using Grpc.Core; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; -using ZB.MOM.WW.LmxProxy.Host.Grpc.Services; -using ZB.MOM.WW.LmxProxy.Host.MxAccess; -using ZB.MOM.WW.LmxProxy.Host.Security; -using ZB.MOM.WW.LmxProxy.Host.Sessions; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - /// - /// Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue. - /// - public class LmxProxyService - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly LmxProxyConfiguration _config; - - private MxAccessClient? _mxAccessClient; - private SessionManager? _sessionManager; - private SubscriptionManager? _subscriptionManager; - private ApiKeyService? _apiKeyService; - private Server? _grpcServer; - - public LmxProxyService(LmxProxyConfiguration config) - { - _config = config; - } - - /// - /// Topshelf Start callback. Creates and starts all components. - /// - public bool Start() - { - try - { - Log.Information("LmxProxy service starting..."); - - // 1. Validate configuration - ConfigurationValidator.ValidateAndLog(_config); - - // 2. Check/generate TLS certificates - var credentials = TlsCertificateManager.CreateServerCredentials(_config.Tls); - - // 3. Create ApiKeyService - _apiKeyService = new ApiKeyService(_config.ApiKeyConfigFile); - - // 4. Create MxAccessClient - _mxAccessClient = new MxAccessClient( - maxConcurrentOperations: _config.Connection.MaxConcurrentOperations, - readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds, - writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds, - monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds, - autoReconnect: _config.Connection.AutoReconnect, - nodeName: _config.Connection.NodeName, - galaxyName: _config.Connection.GalaxyName); - - // 5. Connect to MxAccess synchronously (with timeout) - Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...", - _config.Connection.ConnectionTimeoutSeconds); - using (var cts = new CancellationTokenSource( - TimeSpan.FromSeconds(_config.Connection.ConnectionTimeoutSeconds))) - { - _mxAccessClient.ConnectAsync(cts.Token).GetAwaiter().GetResult(); - } - - // 6. Start auto-reconnect monitor - _mxAccessClient.StartMonitorLoop(); - - // 7. Create SubscriptionManager - var channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest; - if (_config.Subscription.ChannelFullMode.Equals("DropNewest", StringComparison.OrdinalIgnoreCase)) - channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropNewest; - else if (_config.Subscription.ChannelFullMode.Equals("Wait", StringComparison.OrdinalIgnoreCase)) - channelFullMode = System.Threading.Channels.BoundedChannelFullMode.Wait; - - _subscriptionManager = new SubscriptionManager( - _mxAccessClient, _config.Subscription.ChannelCapacity, channelFullMode); - - // Wire MxAccessClient data change events to SubscriptionManager - _mxAccessClient.OnTagValueChanged = _subscriptionManager.OnTagValueChanged; - - // Wire MxAccessClient disconnect to SubscriptionManager - _mxAccessClient.ConnectionStateChanged += (sender, e) => - { - if (e.CurrentState == Domain.ConnectionState.Disconnected || - e.CurrentState == Domain.ConnectionState.Error) - { - _subscriptionManager.NotifyDisconnection(); - } - }; - - // 8. Create SessionManager - _sessionManager = new SessionManager(inactivityTimeoutMinutes: 5); - - // 9. Create gRPC service - var grpcService = new ScadaGrpcService( - _mxAccessClient, _sessionManager, _subscriptionManager); - - // 10. Create and configure interceptor - var interceptor = new ApiKeyInterceptor(_apiKeyService); - - // 11. Build and start gRPC server - _grpcServer = new Server - { - Services = - { - Scada.ScadaService.BindService(grpcService) - .Intercept(interceptor) - }, - Ports = - { - new ServerPort("0.0.0.0", _config.GrpcPort, credentials) - } - }; - - _grpcServer.Start(); - Log.Information("gRPC server started on port {Port}", _config.GrpcPort); - - Log.Information("LmxProxy service started successfully"); - return true; - } - catch (Exception ex) - { - Log.Fatal(ex, "LmxProxy service failed to start"); - return false; - } - } - - /// - /// Topshelf Stop callback. Stops and disposes all components in reverse order. - /// - public bool Stop() - { - Log.Information("LmxProxy service stopping..."); - - try - { - // 1. Stop reconnect monitor (5s wait) - _mxAccessClient?.StopMonitorLoop(); - - // 2. Graceful gRPC shutdown (10s timeout, then kill) - if (_grpcServer != null) - { - Log.Information("Shutting down gRPC server..."); - _grpcServer.ShutdownAsync().Wait(TimeSpan.FromSeconds(10)); - Log.Information("gRPC server stopped"); - } - - // 3. Dispose components in reverse order - _subscriptionManager?.Dispose(); - _sessionManager?.Dispose(); - _apiKeyService?.Dispose(); - - // 4. Disconnect MxAccess (10s timeout) - if (_mxAccessClient != null) - { - Log.Information("Disconnecting from MxAccess..."); - _mxAccessClient.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10)); - Log.Information("MxAccess disconnected"); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error during shutdown"); - } - - Log.Information("LmxProxy service stopped"); - return true; - } - - /// Topshelf Pause callback — no-op. - public bool Pause() - { - Log.Information("LmxProxy service paused (no-op)"); - return true; - } - - /// Topshelf Continue callback — no-op. - public bool Continue() - { - Log.Information("LmxProxy service continued (no-op)"); - return true; - } - } -} -``` - ---- - -## Step 8: Program.cs - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` - -Replace the Phase 1 placeholder with the full Topshelf entry point: - -```csharp -using System; -using Microsoft.Extensions.Configuration; -using Serilog; -using Topshelf; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - internal static class Program - { - static int Main(string[] args) - { - // 1. Build configuration - var configuration = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) - .AddEnvironmentVariables() - .Build(); - - // 2. Configure Serilog - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.FromLogContext() - .Enrich.WithMachineName() - .Enrich.WithThreadId() - .CreateLogger(); - - try - { - // 3. Bind configuration - var config = new LmxProxyConfiguration(); - configuration.Bind(config); - - // 4. Configure Topshelf - var exitCode = HostFactory.Run(host => - { - host.UseSerilog(); - - host.Service(service => - { - service.ConstructUsing(() => new LmxProxyService(config)); - service.WhenStarted(s => s.Start()); - service.WhenStopped(s => s.Stop()); - service.WhenPaused(s => s.Pause()); - service.WhenContinued(s => s.Continue()); - service.WhenShutdown(s => s.Stop()); - }); - - host.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); - host.SetDisplayName("SCADA Bridge LMX Proxy"); - host.SetDescription("gRPC proxy for AVEVA System Platform via MXAccess COM API"); - - host.StartAutomatically(); - host.EnablePauseAndContinue(); - - host.EnableServiceRecovery(recovery => - { - recovery.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); - recovery.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); - recovery.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); - recovery.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); - }); - }); - - return (int)exitCode; - } - catch (Exception ex) - { - Log.Fatal(ex, "LmxProxy service terminated unexpectedly"); - return 1; - } - finally - { - Log.CloseAndFlush(); - } - } - } -} -``` - ---- - -## Step 9: appsettings.json - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` - -Replace the Phase 1 placeholder with the complete default configuration: - -```json -{ - "GrpcPort": 50051, - "ApiKeyConfigFile": "apikeys.json", - - "Connection": { - "MonitorIntervalSeconds": 5, - "ConnectionTimeoutSeconds": 30, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10, - "AutoReconnect": true, - "NodeName": null, - "GalaxyName": null - }, - - "Subscription": { - "ChannelCapacity": 1000, - "ChannelFullMode": "DropOldest" - }, - - "Tls": { - "Enabled": false, - "ServerCertificatePath": "certs/server.crt", - "ServerKeyPath": "certs/server.key", - "ClientCaCertificatePath": "certs/ca.crt", - "RequireClientCertificate": false, - "CheckCertificateRevocation": false - }, - - "WebServer": { - "Enabled": true, - "Port": 8080 - }, - - "ServiceRecovery": { - "FirstFailureDelayMinutes": 1, - "SecondFailureDelayMinutes": 5, - "SubsequentFailureDelayMinutes": 10, - "ResetPeriodDays": 1 - }, - - "Serilog": { - "Using": [ - "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Enrichers.Environment", - "Serilog.Enrichers.Thread" - ], - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning", - "Grpc": "Information" - } - }, - "WriteTo": [ - { - "Name": "Console", - "Args": { - "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}" - } - } - ], - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ] - } -} -``` - ---- - -## Step 10: Unit tests - -### 10.1 ConfigurationValidator tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs` - -```csharp -using System; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration -{ - public class ConfigurationValidatorTests - { - private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration(); - - [Fact] - public void ValidConfig_PassesValidation() - { - var config = ValidConfig(); - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().NotThrow(); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(70000)] - public void InvalidGrpcPort_Throws(int port) - { - var config = ValidConfig(); - config.GrpcPort = port; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("GrpcPort")); - } - - [Fact] - public void InvalidMonitorInterval_Throws() - { - var config = ValidConfig(); - config.Connection.MonitorIntervalSeconds = 0; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("MonitorIntervalSeconds")); - } - - [Fact] - public void InvalidChannelCapacity_Throws() - { - var config = ValidConfig(); - config.Subscription.ChannelCapacity = -1; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ChannelCapacity")); - } - - [Fact] - public void InvalidChannelFullMode_Throws() - { - var config = ValidConfig(); - config.Subscription.ChannelFullMode = "InvalidMode"; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ChannelFullMode")); - } - - [Fact] - public void InvalidResetPeriodDays_Throws() - { - var config = ValidConfig(); - config.ServiceRecovery.ResetPeriodDays = 0; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ResetPeriodDays")); - } - - [Fact] - public void NegativeFailureDelay_Throws() - { - var config = ValidConfig(); - config.ServiceRecovery.FirstFailureDelayMinutes = -1; - var act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("FirstFailureDelayMinutes")); - } - } -} -``` - -### 10.2 ApiKeyService tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs` - -```csharp -using System; -using System.IO; -using FluentAssertions; -using Newtonsoft.Json; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Security; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security -{ - public class ApiKeyServiceTests : IDisposable - { - private readonly string _tempDir; - - public ApiKeyServiceTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N")[..8]); - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - private string CreateKeyFile(params ApiKey[] keys) - { - var path = Path.Combine(_tempDir, "apikeys.json"); - var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List(keys) }; - File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented)); - return path; - } - - [Fact] - public void AutoGeneratesDefaultFile_WhenMissing() - { - var path = Path.Combine(_tempDir, "missing.json"); - using var svc = new ApiKeyService(path); - File.Exists(path).Should().BeTrue(); - svc.KeyCount.Should().Be(2); - } - - [Fact] - public void ValidateApiKey_ReturnsKey_WhenValid() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using var svc = new ApiKeyService(path); - var key = svc.ValidateApiKey("test-key"); - key.Should().NotBeNull(); - key!.Role.Should().Be(ApiKeyRole.ReadWrite); - } - - [Fact] - public void ValidateApiKey_ReturnsNull_WhenInvalid() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using var svc = new ApiKeyService(path); - svc.ValidateApiKey("wrong-key").Should().BeNull(); - } - - [Fact] - public void ValidateApiKey_ReturnsNull_WhenDisabled() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false }); - using var svc = new ApiKeyService(path); - svc.ValidateApiKey("test-key").Should().BeNull(); - } - - [Fact] - public void HasRole_ReadWrite_CanRead() - { - var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using var svc = new ApiKeyService(path); - svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue(); - } - - [Fact] - public void HasRole_ReadOnly_CannotWrite() - { - var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true }); - using var svc = new ApiKeyService(path); - svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse(); - } - - [Fact] - public void HasRole_ReadWrite_CanWrite() - { - var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using var svc = new ApiKeyService(path); - svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue(); - } - - [Fact] - public void ValidateApiKey_EmptyString_ReturnsNull() - { - var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true }); - using var svc = new ApiKeyService(path); - svc.ValidateApiKey("").Should().BeNull(); - svc.ValidateApiKey(null!).Should().BeNull(); - } - } -} -``` - -### 10.3 ApiKeyInterceptor tests - -Testing the interceptor in isolation requires mocking `ServerCallContext`, which is complex with Grpc.Core. Instead, verify the behavior through integration-style tests or verify the write-protected method set: - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs` - -```csharp -using FluentAssertions; -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security -{ - public class ApiKeyInterceptorTests - { - [Theory] - [InlineData("/scada.ScadaService/Write")] - [InlineData("/scada.ScadaService/WriteBatch")] - [InlineData("/scada.ScadaService/WriteBatchAndWait")] - public void WriteProtectedMethods_AreCorrectlyDefined(string method) - { - // This test verifies the set of write-protected methods is correct. - // The actual interceptor logic is tested via integration tests. - var writeProtected = new System.Collections.Generic.HashSet( - System.StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - writeProtected.Should().Contain(method); - } - - [Theory] - [InlineData("/scada.ScadaService/Connect")] - [InlineData("/scada.ScadaService/Disconnect")] - [InlineData("/scada.ScadaService/GetConnectionState")] - [InlineData("/scada.ScadaService/Read")] - [InlineData("/scada.ScadaService/ReadBatch")] - [InlineData("/scada.ScadaService/Subscribe")] - [InlineData("/scada.ScadaService/CheckApiKey")] - public void ReadMethods_AreNotWriteProtected(string method) - { - var writeProtected = new System.Collections.Generic.HashSet( - System.StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - writeProtected.Should().NotContain(method); - } - } -} -``` - ---- - -## Step 11: Build verification - -```bash -cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy - -# Build Client (works on macOS) -dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj - -# Run Client tests -dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj - -# Host builds on Windows only (net48/x86): -# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj -# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj -``` - ---- - -## Completion Criteria - -- [ ] All 6 configuration classes compile with correct defaults -- [ ] `ConfigurationValidator.ValidateAndLog()` catches all invalid values and tests pass -- [ ] `ApiKey` model and `ApiKeyConfiguration` compile -- [ ] `ApiKeyService` compiles: load, validate, hot-reload, auto-generate, and all tests pass -- [ ] `ApiKeyInterceptor` compiles: x-api-key extraction, validation, write protection -- [ ] `TlsCertificateManager` compiles: insecure fallback, server TLS, mutual TLS -- [ ] `ScadaGrpcService` compiles with all 10 RPCs implemented: - - Connect, Disconnect, GetConnectionState - - Read, ReadBatch (TypedValue + QualityCode responses) - - Write, WriteBatch, WriteBatchAndWait (TypedValue input, TypedValueEquals for flag comparison) - - Subscribe (server streaming from SubscriptionManager channel) - - CheckApiKey -- [ ] `LmxProxyService` compiles with full Start/Stop/Pause/Continue lifecycle -- [ ] `Program.cs` compiles with Topshelf configuration, Serilog setup, service recovery -- [ ] `appsettings.json` contains all default configuration values -- [ ] All unit tests pass (ConfigurationValidator, ApiKeyService, ApiKeyInterceptor) -- [ ] No v1 string serialization code: no `ParseValue()`, no `ConvertValueToString()`, no string quality comparisons -- [ ] All quality codes use `QualityCodeMapper` factory methods -- [ ] All value conversions use `TypedValueConverter` diff --git a/deprecated/lmxproxy/docs/plans/phase-4-host-health-metrics.md b/deprecated/lmxproxy/docs/plans/phase-4-host-health-metrics.md deleted file mode 100644 index aa12daf..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-4-host-health-metrics.md +++ /dev/null @@ -1,666 +0,0 @@ -# Phase 4: Host Health, Metrics & Status Server — Implementation Plan - -**Date**: 2026-03-21 -**Prerequisites**: Phase 3 complete and passing (gRPC server, Security, Configuration, Service Hosting all functional) -**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` - -## Guardrails - -1. **This is a v2 rebuild** — do not copy code from the v1 reference in `src-reference/`. Write fresh implementations guided by the design docs and the reference code's structure. -2. **Host targets .NET Framework 4.8, x86** — all code must use C# 9.0 language features maximum (`LangVersion` is `9.0` in the csproj). No file-scoped namespaces, no `required` keyword, no collection expressions in Host code. -3. **No new NuGet packages** — all required packages are already in the Host `.csproj` (`Microsoft.Extensions.Diagnostics.HealthChecks`, `Serilog`, `System.Threading.Channels`, `System.Text.Json` via framework). -4. **Namespace**: `ZB.MOM.WW.LmxProxy.Host` with sub-namespaces matching folder structure (e.g., `ZB.MOM.WW.LmxProxy.Host.Health`, `ZB.MOM.WW.LmxProxy.Host.Metrics`, `ZB.MOM.WW.LmxProxy.Host.Status`). -5. **All COM operations are on the STA thread** — health checks that read test tags must go through `MxAccessClient.ReadAsync()`, never directly touching COM objects. -6. **Build must pass after each step**: `dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86` -7. **Tests run on windev**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86` - -## Step 1: Create PerformanceMetrics - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs` - -Create the `PerformanceMetrics` class in namespace `ZB.MOM.WW.LmxProxy.Host.Metrics`. - -### 1.1 OperationMetrics (nested or separate class in same file) - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Metrics -{ - public class OperationMetrics - { - private readonly List _durations = new List(); - private readonly object _lock = new object(); - private long _totalCount; - private long _successCount; - private double _totalMilliseconds; - private double _minMilliseconds = double.MaxValue; - private double _maxMilliseconds; - - public void Record(TimeSpan duration, bool success) { ... } - public MetricsStatistics GetStatistics() { ... } - } -} -``` - -Implementation details: -- `Record(TimeSpan duration, bool success)`: Inside `lock (_lock)`, increment `_totalCount`, conditionally increment `_successCount`, add `duration.TotalMilliseconds` to `_durations` list, update `_totalMilliseconds`, `_minMilliseconds`, `_maxMilliseconds`. If `_durations.Count > 1000`, call `_durations.RemoveAt(0)` to maintain rolling buffer. -- `GetStatistics()`: Inside `lock (_lock)`, return early with empty `MetricsStatistics` if `_totalCount == 0`. Otherwise sort `_durations`, compute p95 index as `(int)Math.Ceiling(sortedDurations.Count * 0.95) - 1`, clamp to `Math.Max(0, p95Index)`. - -### 1.2 MetricsStatistics - -```csharp -public class MetricsStatistics -{ - public long TotalCount { get; set; } - public long SuccessCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - public double Percentile95Milliseconds { get; set; } -} -``` - -### 1.3 ITimingScope interface and TimingScope implementation - -```csharp -public interface ITimingScope : IDisposable -{ - void SetSuccess(bool success); -} -``` - -`TimingScope` is a private nested class inside `PerformanceMetrics`: -- Constructor takes `PerformanceMetrics metrics, string operationName`, starts a `Stopwatch`. -- `SetSuccess(bool success)` stores the flag (default `true`). -- `Dispose()`: stops stopwatch, calls `_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success)`. Guard against double-dispose with `_disposed` flag. - -### 1.4 PerformanceMetrics class - -```csharp -public class PerformanceMetrics : IDisposable -{ - private static readonly ILogger Logger = Log.ForContext(); - private readonly ConcurrentDictionary _metrics = new ConcurrentDictionary(); - private readonly Timer _reportingTimer; - private bool _disposed; - - public PerformanceMetrics() - { - _reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - - public void RecordOperation(string operationName, TimeSpan duration, bool success = true) { ... } - public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName); - public OperationMetrics? GetMetrics(string operationName) { ... } - public IReadOnlyDictionary GetAllMetrics() { ... } - public Dictionary GetStatistics() { ... } - - private void ReportMetrics(object? state) { ... } // Log each operation's stats at Information level - public void Dispose() { ... } // Dispose timer, call ReportMetrics one final time -} -``` - -`ReportMetrics` iterates `_metrics`, calls `GetStatistics()` on each, logs via Serilog structured logging with properties: `Operation`, `Count`, `SuccessRate`, `AverageMs`, `MinMs`, `MaxMs`, `P95Ms`. - -### 1.5 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" -``` - -## Step 2: Create HealthCheckService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs` - -Namespace: `ZB.MOM.WW.LmxProxy.Host.Health` - -### 2.1 Basic HealthCheckService - -```csharp -public class HealthCheckService : IHealthCheck -{ - private static readonly ILogger Logger = Log.ForContext(); - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - private readonly PerformanceMetrics _performanceMetrics; - - public HealthCheckService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics) { ... } - - public Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) { ... } -} -``` - -Dependencies imported: -- `ZB.MOM.WW.LmxProxy.Host.Domain` for `IScadaClient`, `ConnectionState` -- `ZB.MOM.WW.LmxProxy.Host.Services` for `SubscriptionManager` (if still in that namespace after Phase 2/3; adjust import to match actual location) -- `ZB.MOM.WW.LmxProxy.Host.Metrics` for `PerformanceMetrics` -- `Microsoft.Extensions.Diagnostics.HealthChecks` for `IHealthCheck`, `HealthCheckResult`, `HealthCheckContext` - -`CheckHealthAsync` logic: -1. Create `Dictionary data`. -2. Read `_scadaClient.IsConnected` and `_scadaClient.ConnectionState` into `data["scada_connected"]` and `data["scada_connection_state"]`. -3. Get subscription stats via `_subscriptionManager.GetSubscriptionStats()` — store `TotalClients`, `TotalTags` in data. -4. Iterate `_performanceMetrics.GetAllMetrics()` to compute `totalOperations` and `averageSuccessRate`. -5. Store `total_operations` and `average_success_rate` in data. -6. Decision tree: - - If `!isConnected` → `HealthCheckResult.Unhealthy("SCADA client is not connected", data: data)` - - If `averageSuccessRate < 0.5 && totalOperations > 100` → `HealthCheckResult.Degraded(...)` - - If `subscriptionStats.TotalClients > 100` → `HealthCheckResult.Degraded(...)` - - Otherwise → `HealthCheckResult.Healthy("LmxProxy is healthy", data)` -7. Wrap everything in try/catch — on exception return `Unhealthy` with exception details. - -### 2.2 DetailedHealthCheckService - -In the same file or a separate file `src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs`: - -```csharp -public class DetailedHealthCheckService : IHealthCheck -{ - private static readonly ILogger Logger = Log.ForContext(); - private readonly IScadaClient _scadaClient; - private readonly string _testTagAddress; - - public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "TestChildObject.TestBool") { ... } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) { ... } -} -``` - -`CheckHealthAsync` logic: -1. If `!_scadaClient.IsConnected` → return `Unhealthy`. -2. Try `Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken)`. -3. If `vtq.Quality != Quality.Good` → return `Degraded` with quality info. -4. If `DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)` → return `Degraded` (stale data). -5. Otherwise → `Healthy`. -6. Catch read exceptions → return `Degraded("Could not read test tag")`. -7. Catch all exceptions → return `Unhealthy`. - -### 2.3 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" -``` - -## Step 3: Create StatusReportService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs` - -Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` - -### 3.1 Data model classes - -Define in the same file (or a separate `StatusModels.cs` in the same folder): - -```csharp -public class StatusData -{ - public DateTime Timestamp { get; set; } - public string ServiceName { get; set; } = ""; - public string Version { get; set; } = ""; - public ConnectionStatus Connection { get; set; } = new ConnectionStatus(); - public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus(); - public PerformanceStatus Performance { get; set; } = new PerformanceStatus(); - public HealthInfo Health { get; set; } = new HealthInfo(); - public HealthInfo? DetailedHealth { get; set; } -} - -public class ConnectionStatus -{ - public bool IsConnected { get; set; } - public string State { get; set; } = ""; - public string NodeName { get; set; } = ""; - public string GalaxyName { get; set; } = ""; -} - -public class SubscriptionStatus -{ - public int TotalClients { get; set; } - public int TotalTags { get; set; } - public int ActiveSubscriptions { get; set; } -} - -public class PerformanceStatus -{ - public long TotalOperations { get; set; } - public double AverageSuccessRate { get; set; } - public Dictionary Operations { get; set; } = new Dictionary(); -} - -public class OperationStatus -{ - public long TotalCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - public double Percentile95Milliseconds { get; set; } -} - -public class HealthInfo -{ - public string Status { get; set; } = ""; - public string Description { get; set; } = ""; - public Dictionary Data { get; set; } = new Dictionary(); -} -``` - -### 3.2 StatusReportService - -```csharp -public class StatusReportService -{ - private static readonly ILogger Logger = Log.ForContext(); - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - private readonly PerformanceMetrics _performanceMetrics; - private readonly HealthCheckService _healthCheckService; - private readonly DetailedHealthCheckService? _detailedHealthCheckService; - - public StatusReportService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics, - HealthCheckService healthCheckService, - DetailedHealthCheckService? detailedHealthCheckService = null) { ... } - - public async Task GenerateHtmlReportAsync() { ... } - public async Task GenerateJsonReportAsync() { ... } - public async Task IsHealthyAsync() { ... } - private async Task CollectStatusDataAsync() { ... } - private static string GenerateHtmlFromStatusData(StatusData statusData) { ... } - private static string GenerateErrorHtml(Exception ex) { ... } -} -``` - -`CollectStatusDataAsync`: -- Populate `StatusData.Timestamp = DateTime.UtcNow`, `ServiceName = "ZB.MOM.WW.LmxProxy.Host"`, `Version` from `Assembly.GetExecutingAssembly().GetName().Version`. -- Connection info from `_scadaClient.IsConnected`, `_scadaClient.ConnectionState`. -- Subscription stats from `_subscriptionManager.GetSubscriptionStats()`. -- Performance stats from `_performanceMetrics.GetStatistics()` — include P95 in the `OperationStatus`. -- Health from `_healthCheckService.CheckHealthAsync(new HealthCheckContext())`. -- Detailed health from `_detailedHealthCheckService?.CheckHealthAsync(new HealthCheckContext())` if not null. - -`GenerateJsonReportAsync`: -- Use `System.Text.Json.JsonSerializer.Serialize(statusData, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })`. - -`GenerateHtmlFromStatusData`: -- Use `StringBuilder` to generate self-contained HTML. -- Include inline CSS (Bootstrap-like grid, status cards with color-coded left borders). -- Color coding: green (#28a745) for Healthy/Connected, yellow (#ffc107) for Degraded, red (#dc3545) for Unhealthy/Disconnected. -- Operations table with columns: Operation, Count, Success Rate, Avg (ms), Min (ms), Max (ms), P95 (ms). -- `` for auto-refresh. -- Last updated timestamp at the bottom. - -`IsHealthyAsync`: -- Run basic health check, return `result.Status == HealthStatus.Healthy`. - -### 3.3 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" -``` - -## Step 4: Create StatusWebServer - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs` - -Namespace: `ZB.MOM.WW.LmxProxy.Host.Status` - -```csharp -public class StatusWebServer : IDisposable -{ - private static readonly ILogger Logger = Log.ForContext(); - private readonly WebServerConfiguration _configuration; - private readonly StatusReportService _statusReportService; - private HttpListener? _httpListener; - private CancellationTokenSource? _cancellationTokenSource; - private Task? _listenerTask; - private bool _disposed; - - public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) { ... } - - public bool Start() { ... } - public bool Stop() { ... } - public void Dispose() { ... } - - private async Task HandleRequestsAsync(CancellationToken cancellationToken) { ... } - private async Task HandleRequestAsync(HttpListenerContext context) { ... } - private async Task HandleStatusPageAsync(HttpListenerResponse response) { ... } - private async Task HandleStatusApiAsync(HttpListenerResponse response) { ... } - private async Task HandleHealthApiAsync(HttpListenerResponse response) { ... } - private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType) { ... } -} -``` - -### 4.1 Start() - -1. If `!_configuration.Enabled`, log info and return `true`. -2. Create `HttpListener`, add prefix `_configuration.Prefix ?? $"http://+:{_configuration.Port}/"` (ensure trailing `/`). -3. Call `_httpListener.Start()`. -4. Create `_cancellationTokenSource = new CancellationTokenSource()`. -5. Start `_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token))`. -6. On exception, log error and return `false`. - -### 4.2 Stop() - -1. If not enabled or listener is null, return `true`. -2. Cancel `_cancellationTokenSource`. -3. Wait for `_listenerTask` with 5-second timeout. -4. Stop and close `_httpListener`. - -### 4.3 HandleRequestsAsync - -- Loop while not cancelled and listener is listening. -- `await _httpListener.GetContextAsync()` — on success, spawn `Task.Run` to handle. -- Catch `ObjectDisposedException` and `HttpListenerException(995)` as expected shutdown signals. -- On other errors, log and delay 1 second before continuing. - -### 4.4 HandleRequestAsync routing - -| Path (lowered) | Handler | -|---|---| -| `/` | `HandleStatusPageAsync` — calls `_statusReportService.GenerateHtmlReportAsync()`, content type `text/html; charset=utf-8` | -| `/api/status` | `HandleStatusApiAsync` — calls `_statusReportService.GenerateJsonReportAsync()`, content type `application/json; charset=utf-8` | -| `/api/health` | `HandleHealthApiAsync` — calls `_statusReportService.IsHealthyAsync()`, returns `"OK"` (200) or `"UNHEALTHY"` (503) as `text/plain` | -| Non-GET method | Return 405 Method Not Allowed | -| Unknown path | Return 404 Not Found | -| Exception | Return 500 Internal Server Error | - -### 4.5 WriteResponseAsync - -- Set `Content-Type`, add `Cache-Control: no-cache, no-store, must-revalidate`, `Pragma: no-cache`, `Expires: 0`. -- Convert content to UTF-8 bytes, set `ContentLength64`, write to `response.OutputStream`. - -### 4.6 Dispose - -- Guard with `_disposed` flag. Call `Stop()`. Dispose `_cancellationTokenSource` and close `_httpListener`. - -### 4.7 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" -``` - -## Step 5: Wire into LmxProxyService - -**File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` - -This file already exists. Modify the `Start()` method to create and wire the new components. The v2 rebuild should create these fresh, but the wiring pattern follows the same order as the reference. - -### 5.1 Add using directives - -```csharp -using ZB.MOM.WW.LmxProxy.Host.Health; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Status; -``` - -### 5.2 Add fields - -```csharp -private PerformanceMetrics? _performanceMetrics; -private HealthCheckService? _healthCheckService; -private DetailedHealthCheckService? _detailedHealthCheckService; -private StatusReportService? _statusReportService; -private StatusWebServer? _statusWebServer; -``` - -### 5.3 In Start(), after SessionManager and SubscriptionManager creation - -```csharp -// Create performance metrics -_performanceMetrics = new PerformanceMetrics(); - -// Create health check services -_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics); -_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient); - -// Create status report service -_statusReportService = new StatusReportService( - _scadaClient, _subscriptionManager, _performanceMetrics, - _healthCheckService, _detailedHealthCheckService); - -// Start status web server -_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService); -if (!_statusWebServer.Start()) -{ - Logger.Warning("Status web server failed to start — continuing without it"); -} -``` - -### 5.4 In Stop(), before gRPC server shutdown - -```csharp -// Stop status web server -_statusWebServer?.Stop(); - -// Dispose performance metrics -_performanceMetrics?.Dispose(); -``` - -### 5.5 Pass _performanceMetrics to ScadaGrpcService constructor - -Ensure `ScadaGrpcService` receives `_performanceMetrics` so it can record timings on each RPC call. The gRPC service should call `_performanceMetrics.BeginOperation("Read")` (etc.) and dispose the timing scope at the end of each RPC handler. - -### 5.6 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86" -``` - -## Step 6: Unit Tests - -**Project**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/` - -If this project does not exist yet, create it: - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Host.Tests -o tests/ZB.MOM.WW.LmxProxy.Host.Tests --framework net48" -``` - -**Csproj adjustments** for `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj`: -- `net48` -- `x86` -- `9.0` -- Add `` -- Add `` -- Add `` -- Add `` (for mocking IScadaClient) -- Add `` - -**Also add to solution** in `ZB.MOM.WW.LmxProxy.slnx`: -```xml - - - -``` - -### 6.1 PerformanceMetrics Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics -{ - public class PerformanceMetricsTests - { - [Fact] - public void RecordOperation_TracksCountAndDuration() - // Record 5 operations, verify GetStatistics returns TotalCount=5 - - [Fact] - public void RecordOperation_TracksSuccessAndFailure() - // Record 3 success + 2 failure, verify SuccessRate == 0.6 - - [Fact] - public void GetStatistics_CalculatesP95Correctly() - // Record 100 operations with known durations (1ms through 100ms) - // Verify P95 is approximately 95ms - - [Fact] - public void RollingBuffer_CapsAt1000Samples() - // Record 1500 operations, verify _durations list doesn't exceed 1000 - // (test via GetStatistics behavior — TotalCount is 1500 but percentile computed from 1000) - - [Fact] - public void BeginOperation_RecordsDurationOnDispose() - // Use BeginOperation, await Task.Delay(50), dispose scope - // Verify recorded duration >= 50ms - - [Fact] - public void TimingScope_DefaultsToSuccess() - // BeginOperation + dispose without calling SetSuccess - // Verify SuccessCount == 1 - - [Fact] - public void TimingScope_RespectsSetSuccessFalse() - // BeginOperation, SetSuccess(false), dispose - // Verify SuccessCount == 0, TotalCount == 1 - - [Fact] - public void GetMetrics_ReturnsNullForUnknownOperation() - - [Fact] - public void GetAllMetrics_ReturnsAllTrackedOperations() - } -} -``` - -### 6.2 HealthCheckService Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs` - -Use NSubstitute to mock `IScadaClient`. Create a real `PerformanceMetrics` instance and a real or mock `SubscriptionManager` (depends on Phase 2/3 implementation — if `SubscriptionManager` has an interface, mock it; if not, use the `GetSubscriptionStats()` approach with a concrete instance). - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health -{ - public class HealthCheckServiceTests - { - [Fact] - public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics() - // Mock: IsConnected=true, ConnectionState=Connected - // SubscriptionStats: TotalClients=5, TotalTags=10 - // PerformanceMetrics: record some successes - // Assert: HealthStatus.Healthy - - [Fact] - public async Task ReturnsUnhealthy_WhenNotConnected() - // Mock: IsConnected=false - // Assert: HealthStatus.Unhealthy, description contains "not connected" - - [Fact] - public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent() - // Mock: IsConnected=true - // Record 200 operations with 40% success rate - // Assert: HealthStatus.Degraded - - [Fact] - public async Task ReturnsDegraded_WhenClientCountOver100() - // Mock: IsConnected=true, SubscriptionStats.TotalClients=150 - // Assert: HealthStatus.Degraded - - [Fact] - public async Task DoesNotFlagLowSuccessRate_Under100Operations() - // Record 50 operations with 0% success rate - // Assert: still Healthy (threshold is > 100 total ops) - } - - public class DetailedHealthCheckServiceTests - { - [Fact] - public async Task ReturnsUnhealthy_WhenNotConnected() - - [Fact] - public async Task ReturnsHealthy_WhenTestTagGoodAndRecent() - // Mock ReadAsync returns Good quality with recent timestamp - // Assert: Healthy - - [Fact] - public async Task ReturnsDegraded_WhenTestTagQualityNotGood() - // Mock ReadAsync returns Uncertain quality - // Assert: Degraded - - [Fact] - public async Task ReturnsDegraded_WhenTestTagTimestampStale() - // Mock ReadAsync returns Good quality but timestamp 10 minutes ago - // Assert: Degraded - - [Fact] - public async Task ReturnsDegraded_WhenTestTagReadThrows() - // Mock ReadAsync throws exception - // Assert: Degraded - } -} -``` - -### 6.3 StatusReportService Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status -{ - public class StatusReportServiceTests - { - [Fact] - public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson() - // Verify JSON contains "serviceName", "connection", "isConnected" (camelCase) - - [Fact] - public async Task GenerateHtmlReportAsync_ContainsAutoRefresh() - // Verify HTML contains - - [Fact] - public async Task IsHealthyAsync_ReturnsTrueWhenHealthy() - - [Fact] - public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy() - - [Fact] - public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics() - // Record some operations, verify JSON includes operation names and stats - } -} -``` - -### 6.4 Run tests - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86 --verbosity normal" -``` - -## Step 7: Build Verification - -Run full solution build and tests: - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" -``` - -If the test project is .NET 4.8 x86, you may need: -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx --platform x86 && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86" -``` - -## Completion Criteria - -- [ ] `PerformanceMetrics` class with `OperationMetrics`, `MetricsStatistics`, `ITimingScope` in `src/ZB.MOM.WW.LmxProxy.Host/Metrics/` -- [ ] `HealthCheckService` and `DetailedHealthCheckService` in `src/ZB.MOM.WW.LmxProxy.Host/Health/` -- [ ] `StatusReportService` with data model classes in `src/ZB.MOM.WW.LmxProxy.Host/Status/` -- [ ] `StatusWebServer` with HTML dashboard, JSON status, and health endpoints in `src/ZB.MOM.WW.LmxProxy.Host/Status/` -- [ ] All components wired into `LmxProxyService.Start()` / `Stop()` -- [ ] `ScadaGrpcService` uses `PerformanceMetrics.BeginOperation()` for Read, ReadBatch, Write, WriteBatch RPCs -- [ ] Unit tests for PerformanceMetrics (recording, percentile, rolling buffer, timing scope) -- [ ] Unit tests for HealthCheckService (healthy, unhealthy, degraded transitions) -- [ ] Unit tests for DetailedHealthCheckService (connected, quality, staleness) -- [ ] Unit tests for StatusReportService (JSON format, HTML format, health aggregation) -- [ ] Solution builds without errors: `dotnet build ZB.MOM.WW.LmxProxy.slnx` -- [ ] All tests pass: `dotnet test` diff --git a/deprecated/lmxproxy/docs/plans/phase-5-client-core.md b/deprecated/lmxproxy/docs/plans/phase-5-client-core.md deleted file mode 100644 index 796ed0c..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-5-client-core.md +++ /dev/null @@ -1,852 +0,0 @@ -# Phase 5: Client Core — Implementation Plan - -**Date**: 2026-03-21 -**Prerequisites**: Phase 1 complete and passing (Protocol & Domain Types — `ScadaContracts.cs` with v2 `TypedValue`/`QualityCode` messages, `Quality.cs`, `QualityExtensions.cs`, `Vtq.cs`, `ConnectionState.cs` all exist and cross-stack serialization tests pass) -**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` - -## Guardrails - -1. **Client targets .NET 10, AnyCPU** — use latest C# features freely. The csproj `` is `net10.0`, `latest`. -2. **Code-first gRPC only** — the Client uses `protobuf-net.Grpc` with `[ServiceContract]`/`[DataContract]` attributes. Never reference proto files or `Grpc.Tools`. -3. **No string serialization heuristics** — v2 uses native `TypedValue`. Do not write `double.TryParse`, `bool.TryParse`, or any string-to-value parsing on tag values. -4. **`status_code` is canonical for quality** — `symbolic_name` is derived. Never set `symbolic_name` independently. -5. **Polly v8 API** — the Client csproj already has ``. Use the v8 `ResiliencePipeline` API, not the legacy v7 `IAsyncPolicy` API. -6. **No new NuGet packages** — all needed packages are already in `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj`. -7. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client` -8. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests` -9. **Namespace root**: `ZB.MOM.WW.LmxProxy.Client` - -## Step 1: ClientTlsConfiguration - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs` - -This file already exists with the correct shape. Verify it has all these properties (from Component-Client.md): - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client; - -public class ClientTlsConfiguration -{ - public bool UseTls { get; set; } = false; - public string? ClientCertificatePath { get; set; } - public string? ClientKeyPath { get; set; } - public string? ServerCaCertificatePath { get; set; } - public string? ServerNameOverride { get; set; } - public bool ValidateServerCertificate { get; set; } = true; - public bool AllowSelfSignedCertificates { get; set; } = false; - public bool IgnoreAllCertificateErrors { get; set; } = false; -} -``` - -If it matches, no changes needed. If any properties are missing, add them. - -## Step 2: Security/GrpcChannelFactory - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs` - -This file already exists. Verify the implementation covers: - -1. `CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)` — returns `GrpcChannel`. -2. Creates `SocketsHttpHandler` with `EnableMultipleHttp2Connections = true`. -3. For TLS: sets `SslProtocols = Tls12 | Tls13`, configures `ServerNameOverride` as `TargetHost`, loads client certificate from PEM files for mTLS. -4. Certificate validation callback handles: `IgnoreAllCertificateErrors`, `!ValidateServerCertificate`, custom CA trust store via `ServerCaCertificatePath`, `AllowSelfSignedCertificates`. -5. Static constructor sets `System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport = true` for non-TLS. - -The existing implementation matches. No changes expected unless Phase 1 introduced breaking changes. - -## Step 3: ILmxProxyClient Interface - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs` - -Rewrite for v2 protocol. The key changes from v1: -- `WriteAsync` and `WriteBatchAsync` accept `TypedValue` instead of `object` -- `SubscribeAsync` has an `onStreamError` callback parameter -- `CheckApiKeyAsync` is added -- Return types use v2 domain `Vtq` (which wraps `TypedValue` + `QualityCode`) - -```csharp -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Interface for LmxProxy client operations. -/// -public interface ILmxProxyClient : IDisposable, IAsyncDisposable -{ - /// Gets or sets the default timeout for operations (range: 1s to 10min). - TimeSpan DefaultTimeout { get; set; } - - /// Connects to the LmxProxy service and establishes a session. - Task ConnectAsync(CancellationToken cancellationToken = default); - - /// Disconnects from the LmxProxy service. - Task DisconnectAsync(); - - /// Returns true if the client has an active session. - Task IsConnectedAsync(); - - /// Reads a single tag value. - Task ReadAsync(string address, CancellationToken cancellationToken = default); - - /// Reads multiple tag values in a single batch. - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); - - /// Writes a single tag value (native TypedValue — no string heuristics). - Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default); - - /// Writes multiple tag values in a single batch. - Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); - - /// - /// Writes a batch of values, then polls a flag tag until it matches or timeout expires. - /// Returns (writeResults, flagReached, elapsedMs). - /// - Task WriteBatchAndWaitAsync( - IDictionary values, - string flagTag, - TypedValue flagValue, - int timeoutMs = 5000, - int pollIntervalMs = 100, - CancellationToken cancellationToken = default); - - /// Subscribes to tag updates with value and error callbacks. - Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default); - - /// Validates an API key and returns info. - Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default); - - /// Returns a snapshot of client-side metrics. - Dictionary GetMetrics(); -} -``` - -**Note**: The `TypedValue` class referenced here is from `Domain/ScadaContracts.cs` — it should already have been updated in Phase 1 to use `[DataContract]` with the v2 oneof-style properties (e.g., `BoolValue`, `Int32Value`, `DoubleValue`, `StringValue`, `DatetimeValue`, etc., with a `ValueCase` enum or similar discriminator). - -## Step 4: LmxProxyClient — Main File - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs` - -This is a partial class. The main file contains the constructor, fields, properties, and the Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey methods. - -### 4.1 Fields and Constructor - -```csharp -public partial class LmxProxyClient : ILmxProxyClient -{ - private readonly ILogger _logger; - private readonly string _host; - private readonly int _port; - private readonly string? _apiKey; - private readonly ClientTlsConfiguration? _tlsConfiguration; - private readonly ClientMetrics _metrics = new(); - private readonly SemaphoreSlim _connectionLock = new(1, 1); - private readonly List _activeSubscriptions = []; - private readonly Lock _subscriptionLock = new(); - - private GrpcChannel? _channel; - private IScadaService? _client; - private string _sessionId = string.Empty; - private bool _disposed; - private bool _isConnected; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private ClientConfiguration? _configuration; - private ResiliencePipeline? _resiliencePipeline; // Polly v8 - private Timer? _keepAliveTimer; - private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30); - - // IsConnected computed property - public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); - - public LmxProxyClient( - string host, int port, string? apiKey, - ClientTlsConfiguration? tlsConfiguration, - ILogger? logger = null) - { - _host = host ?? throw new ArgumentNullException(nameof(host)); - _port = port; - _apiKey = apiKey; - _tlsConfiguration = tlsConfiguration; - _logger = logger ?? NullLogger.Instance; - } - - internal void SetBuilderConfiguration(ClientConfiguration config) - { - _configuration = config; - // Build Polly v8 ResiliencePipeline from config - if (config.MaxRetryAttempts > 0) - { - _resiliencePipeline = new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = config.MaxRetryAttempts, - Delay = config.RetryDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder() - .Handle(ex => - ex.StatusCode == StatusCode.Unavailable || - ex.StatusCode == StatusCode.DeadlineExceeded || - ex.StatusCode == StatusCode.ResourceExhausted || - ex.StatusCode == StatusCode.Aborted), - OnRetry = args => - { - _logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}", - args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message); - return ValueTask.CompletedTask; - } - }) - .Build(); - } - } -} -``` - -### 4.2 ReadAsync - -```csharp -public async Task ReadAsync(string address, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - _metrics.IncrementOperationCount("Read"); - var sw = Stopwatch.StartNew(); - try - { - var request = new ReadRequest { SessionId = _sessionId, Tag = address }; - ReadResponse response = await ExecuteWithRetry( - () => _client!.ReadAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"Read failed: {response.Message}"); - return ConvertVtqMessage(response.Vtq); - } - catch (Exception ex) - { - _metrics.IncrementErrorCount("Read"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("Read", sw.ElapsedMilliseconds); - } -} -``` - -### 4.3 ReadBatchAsync - -```csharp -public async Task> ReadBatchAsync( - IEnumerable addresses, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - _metrics.IncrementOperationCount("ReadBatch"); - var sw = Stopwatch.StartNew(); - try - { - var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() }; - ReadBatchResponse response = await ExecuteWithRetry( - () => _client!.ReadBatchAsync(request).AsTask(), cancellationToken); - var result = new Dictionary(); - foreach (var vtqMsg in response.Vtqs) - { - result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg); - } - return result; - } - catch - { - _metrics.IncrementErrorCount("ReadBatch"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds); - } -} -``` - -### 4.4 WriteAsync - -```csharp -public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - _metrics.IncrementOperationCount("Write"); - var sw = Stopwatch.StartNew(); - try - { - var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value }; - WriteResponse response = await ExecuteWithRetry( - () => _client!.WriteAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"Write failed: {response.Message}"); - } - catch - { - _metrics.IncrementErrorCount("Write"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("Write", sw.ElapsedMilliseconds); - } -} -``` - -### 4.5 WriteBatchAsync - -```csharp -public async Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - _metrics.IncrementOperationCount("WriteBatch"); - var sw = Stopwatch.StartNew(); - try - { - var request = new WriteBatchRequest - { - SessionId = _sessionId, - Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList() - }; - WriteBatchResponse response = await ExecuteWithRetry( - () => _client!.WriteBatchAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); - } - catch - { - _metrics.IncrementErrorCount("WriteBatch"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds); - } -} -``` - -### 4.6 WriteBatchAndWaitAsync - -```csharp -public async Task WriteBatchAndWaitAsync( - IDictionary values, string flagTag, TypedValue flagValue, - int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - var request = new WriteBatchAndWaitRequest - { - SessionId = _sessionId, - Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(), - FlagTag = flagTag, - FlagValue = flagValue, - TimeoutMs = timeoutMs, - PollIntervalMs = pollIntervalMs - }; - return await ExecuteWithRetry( - () => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken); -} -``` - -### 4.7 CheckApiKeyAsync - -```csharp -public async Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) -{ - EnsureConnected(); - var request = new CheckApiKeyRequest { ApiKey = apiKey }; - CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request); - return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message }; -} -``` - -### 4.8 ConvertVtqMessage helper - -This converts the wire `VtqMessage` (v2 with `TypedValue` + `QualityCode`) to the domain `Vtq`: - -```csharp -private static Vtq ConvertVtqMessage(VtqMessage? msg) -{ - if (msg is null) - return new Vtq(null, DateTime.UtcNow, Quality.Bad); - - object? value = ExtractTypedValue(msg.Value); - DateTime timestamp = msg.TimestampUtcTicks > 0 - ? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc) - : DateTime.UtcNow; - Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u); - return new Vtq(value, timestamp, quality); -} - -private static object? ExtractTypedValue(TypedValue? tv) -{ - if (tv is null) return null; - // Switch on whichever oneof-style property is set - // The exact property names depend on the Phase 1 code-first contract design - // e.g., tv.BoolValue, tv.Int32Value, tv.DoubleValue, tv.StringValue, etc. - // Return the native .NET value directly — no string conversions - ... -} -``` - -**Important**: The exact shape of `TypedValue` in code-first contracts depends on Phase 1's implementation. Phase 1 should have defined a discriminator pattern (e.g., `ValueCase` enum or nullable properties with a convention). Adapt `ExtractTypedValue` to whatever pattern was chosen. The key rule: **no string heuristics**. - -### 4.9 ExecuteWithRetry helper - -```csharp -private async Task ExecuteWithRetry(Func> operation, CancellationToken ct) -{ - if (_resiliencePipeline is not null) - { - return await _resiliencePipeline.ExecuteAsync( - async token => await operation(), ct); - } - return await operation(); -} -``` - -### 4.10 EnsureConnected, Dispose, DisposeAsync - -```csharp -private void EnsureConnected() -{ - ObjectDisposedException.ThrowIf(_disposed, this); - if (!IsConnected) - throw new InvalidOperationException("Client is not connected. Call ConnectAsync first."); -} - -public void Dispose() -{ - if (_disposed) return; - _disposed = true; - _keepAliveTimer?.Dispose(); - _channel?.Dispose(); - _connectionLock.Dispose(); -} - -public async ValueTask DisposeAsync() -{ - if (_disposed) return; - try { await DisconnectAsync(); } catch { /* swallow */ } - Dispose(); -} -``` - -### 4.11 IsConnectedAsync - -```csharp -public Task IsConnectedAsync() => Task.FromResult(IsConnected); -``` - -### 4.12 GetMetrics - -```csharp -public Dictionary GetMetrics() => _metrics.GetSnapshot(); -``` - -### 4.13 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 5: LmxProxyClient.Connection - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs` - -Partial class containing `ConnectAsync`, `DisconnectAsync`, keep-alive, `MarkDisconnectedAsync`, `BuildEndpointUri`. - -### 5.1 ConnectAsync - -1. Acquire `_connectionLock`. -2. Throw `ObjectDisposedException` if disposed. -3. Return early if already connected. -4. Build endpoint URI via `BuildEndpointUri()`. -5. Create channel: `GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger)`. -6. Create code-first client: `channel.CreateGrpcService()` (from `ProtoBuf.Grpc.Client`). -7. Send `ConnectRequest` with `ClientId = $"ScadaBridge-{Guid.NewGuid():N}"` and `ApiKey = _apiKey ?? string.Empty`. -8. If `!response.Success`, dispose channel and throw. -9. Store channel, client, sessionId. Set `_isConnected = true`. -10. Call `StartKeepAlive()`. -11. On failure, reset all state and rethrow. -12. Release lock in `finally`. - -### 5.2 DisconnectAsync - -1. Acquire `_connectionLock`. -2. Stop keep-alive. -3. If client and session exist, send `DisconnectRequest`. Swallow exceptions. -4. Clear client, sessionId, isConnected. Dispose channel. -5. Release lock. - -### 5.3 Keep-alive timer - -- `StartKeepAlive()`: creates `Timer` with `_keepAliveInterval` (30s) interval. -- Timer callback: sends `GetConnectionStateRequest`. On failure: stops timer, calls `MarkDisconnectedAsync(ex)`. -- `StopKeepAlive()`: disposes timer, nulls it. - -### 5.4 MarkDisconnectedAsync - -1. If disposed, return. -2. Acquire `_connectionLock`, set `_isConnected = false`, clear client/sessionId, dispose channel. Release lock. -3. Copy and clear `_activeSubscriptions` under `_subscriptionLock`. -4. Dispose each subscription (swallow errors). -5. Log warning with the exception. - -### 5.5 BuildEndpointUri - -```csharp -private Uri BuildEndpointUri() -{ - string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; - return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri; -} -``` - -### 5.6 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 6: LmxProxyClient.CodeFirstSubscription - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs` - -Nested class inside `LmxProxyClient` implementing `ISubscription`. - -### 6.1 CodeFirstSubscription class - -```csharp -private class CodeFirstSubscription : ISubscription -{ - private readonly IScadaService _client; - private readonly string _sessionId; - private readonly List _tags; - private readonly Action _onUpdate; - private readonly Action? _onStreamError; - private readonly ILogger _logger; - private readonly Action? _onDispose; - private readonly CancellationTokenSource _cts = new(); - private Task? _processingTask; - private bool _disposed; - private bool _streamErrorFired; -``` - -Constructor takes all of these. `StartAsync` stores `_processingTask = ProcessUpdatesAsync(cancellationToken)`. - -### 6.2 ProcessUpdatesAsync - -```csharp -private async Task ProcessUpdatesAsync(CancellationToken cancellationToken) -{ - try - { - var request = new SubscribeRequest - { - SessionId = _sessionId, - Tags = _tags, - SamplingMs = 1000 - }; - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); - - await foreach (VtqMessage vtqMsg in _client.SubscribeAsync(request, linkedCts.Token)) - { - try - { - Vtq vtq = ConvertVtqMessage(vtqMsg); // static method from outer class - _onUpdate(vtqMsg.Tag, vtq); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag); - } - } - } - catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested) - { - _logger.LogDebug("Subscription cancelled"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in subscription processing"); - FireStreamError(ex); - } - finally - { - if (!_disposed) - { - _disposed = true; - _onDispose?.Invoke(this); - } - } -} - -private void FireStreamError(Exception ex) -{ - if (_streamErrorFired) return; - _streamErrorFired = true; - try { _onStreamError?.Invoke(ex); } - catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); } -} -``` - -**Key difference from v1**: The `ConvertVtqMessage` now handles `TypedValue` + `QualityCode` natively instead of parsing strings. Also, `_onStreamError` callback is invoked exactly once on stream termination (per Component-Client.md section 5.1). - -### 6.3 DisposeAsync and Dispose - -`DisposeAsync()`: Cancel CTS, await `_processingTask` (swallow errors), dispose CTS. 5-second timeout guard. - -`Dispose()`: Calls `DisposeAsync()` synchronously with `Task.Wait(TimeSpan.FromSeconds(5))`. - -### 6.4 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 7: LmxProxyClient.ClientMetrics - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs` - -Internal class. Already exists in v1 reference. Rewrite for v2 with p99 support. - -```csharp -internal class ClientMetrics -{ - private readonly ConcurrentDictionary _operationCounts = new(); - private readonly ConcurrentDictionary _errorCounts = new(); - private readonly ConcurrentDictionary> _latencies = new(); - private readonly Lock _latencyLock = new(); - - public void IncrementOperationCount(string operation) { ... } - public void IncrementErrorCount(string operation) { ... } - public void RecordLatency(string operation, long milliseconds) { ... } - public Dictionary GetSnapshot() { ... } -} -``` - -`RecordLatency`: Under `_latencyLock`, add to list. If count > 1000, `RemoveAt(0)`. - -`GetSnapshot`: Returns dictionary with keys `{op}_count`, `{op}_errors`, `{op}_avg_latency_ms`, `{op}_p95_latency_ms`, `{op}_p99_latency_ms`. - -`GetPercentile(List values, int percentile)`: Sort, compute index as `(int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1`, clamp with `Math.Max(0, ...)`. - -## Step 8: LmxProxyClient.ApiKeyInfo - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs` - -Simple DTO returned by `CheckApiKeyAsync`: - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - /// Result of an API key validation check. - /// - public class ApiKeyInfo - { - public bool IsValid { get; init; } - public string? Role { get; init; } - public string? Description { get; init; } - } -} -``` - -## Step 9: LmxProxyClient.ISubscription - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - /// Represents an active tag subscription. Dispose to unsubscribe. - /// - public interface ISubscription : IDisposable - { - /// Asynchronous disposal with cancellation support. - Task DisposeAsync(); - } -} -``` - -## Step 10: Unit Tests - -**Project**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` - -Create if not exists: - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.Tests -o tests/ZB.MOM.WW.LmxProxy.Client.Tests --framework net10.0" -``` - -**Csproj** for `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj`: -- `net10.0` -- `` -- `` -- `` -- `` -- `` - -**Add to solution** `ZB.MOM.WW.LmxProxy.slnx`: -```xml - - - -``` - -### 10.1 Connection Lifecycle Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs` - -Mock `IScadaService` using NSubstitute. - -```csharp -public class LmxProxyClientConnectionTests -{ - [Fact] - public async Task ConnectAsync_EstablishesSessionAndStartsKeepAlive() - - [Fact] - public async Task ConnectAsync_ThrowsWhenServerReturnsFailure() - - [Fact] - public async Task DisconnectAsync_SendsDisconnectAndClearsState() - - [Fact] - public async Task IsConnectedAsync_ReturnsFalseBeforeConnect() - - [Fact] - public async Task IsConnectedAsync_ReturnsTrueAfterConnect() - - [Fact] - public async Task KeepAliveFailure_MarksDisconnected() -} -``` - -Note: Testing the keep-alive requires either waiting 30s (too slow) or making the interval configurable for tests. Consider passing the interval as an internal constructor parameter or using a test-only subclass. Alternatively, test `MarkDisconnectedAsync` directly. - -### 10.2 Read/Write Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs` - -```csharp -public class LmxProxyClientReadWriteTests -{ - [Fact] - public async Task ReadAsync_ReturnsVtqFromResponse() - // Mock ReadAsync to return a VtqMessage with TypedValue.DoubleValue = 42.5 - // Verify returned Vtq.Value is 42.5 (double) - - [Fact] - public async Task ReadAsync_ThrowsOnFailureResponse() - - [Fact] - public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs() - - [Fact] - public async Task WriteAsync_SendsTypedValueDirectly() - // Verify the WriteRequest.Value is the TypedValue passed in, not a string - - [Fact] - public async Task WriteBatchAsync_SendsAllItems() - - [Fact] - public async Task WriteBatchAndWaitAsync_ReturnsResponse() -} -``` - -### 10.3 Subscription Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs` - -```csharp -public class LmxProxyClientSubscriptionTests -{ - [Fact] - public async Task SubscribeAsync_InvokesCallbackForEachUpdate() - - [Fact] - public async Task SubscribeAsync_InvokesStreamErrorOnFailure() - - [Fact] - public async Task SubscribeAsync_DisposeStopsProcessing() -} -``` - -### 10.4 TypedValue Conversion Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs` - -```csharp -public class TypedValueConversionTests -{ - [Fact] public void ConvertVtqMessage_ExtractsBoolValue() - [Fact] public void ConvertVtqMessage_ExtractsInt32Value() - [Fact] public void ConvertVtqMessage_ExtractsInt64Value() - [Fact] public void ConvertVtqMessage_ExtractsFloatValue() - [Fact] public void ConvertVtqMessage_ExtractsDoubleValue() - [Fact] public void ConvertVtqMessage_ExtractsStringValue() - [Fact] public void ConvertVtqMessage_ExtractsDateTimeValue() - [Fact] public void ConvertVtqMessage_HandlesNullTypedValue() - [Fact] public void ConvertVtqMessage_HandlesNullMessage() - [Fact] public void ConvertVtqMessage_MapsQualityCodeCorrectly() - [Fact] public void ConvertVtqMessage_GoodQualityCode() - [Fact] public void ConvertVtqMessage_BadQualityCode() - [Fact] public void ConvertVtqMessage_UncertainQualityCode() -} -``` - -### 10.5 Metrics Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs` - -```csharp -public class ClientMetricsTests -{ - [Fact] public void IncrementOperationCount_Increments() - [Fact] public void IncrementErrorCount_Increments() - [Fact] public void RecordLatency_StoresValues() - [Fact] public void RollingBuffer_CapsAt1000() - [Fact] public void GetSnapshot_IncludesP95AndP99() -} -``` - -### 10.6 Run tests - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal" -``` - -## Step 11: Build Verification - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" -``` - -## Completion Criteria - -- [ ] `ILmxProxyClient` interface updated for v2 (TypedValue parameters, onStreamError callback, CheckApiKeyAsync) -- [ ] `LmxProxyClient.cs` — main file with Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey using v2 TypedValue -- [ ] `LmxProxyClient.Connection.cs` — ConnectAsync, DisconnectAsync, keep-alive (30s), MarkDisconnectedAsync -- [ ] `LmxProxyClient.CodeFirstSubscription.cs` — IAsyncEnumerable processing, onStreamError callback, 5s dispose timeout -- [ ] `LmxProxyClient.ClientMetrics.cs` — per-op counts/errors/latency, 1000-sample buffer, p95/p99 -- [ ] `LmxProxyClient.ApiKeyInfo.cs` — simple DTO -- [ ] `LmxProxyClient.ISubscription.cs` — IDisposable + DisposeAsync -- [ ] `ClientTlsConfiguration.cs` — all properties present -- [ ] `Security/GrpcChannelFactory.cs` — TLS 1.2/1.3, cert validation, custom CA, self-signed support -- [ ] No string serialization heuristics anywhere in Client code -- [ ] ConvertVtqMessage extracts native TypedValue without parsing -- [ ] Polly v8 ResiliencePipeline for retry (not v7 IAsyncPolicy) -- [ ] All unit tests pass -- [ ] Solution builds cleanly diff --git a/deprecated/lmxproxy/docs/plans/phase-6-client-extras.md b/deprecated/lmxproxy/docs/plans/phase-6-client-extras.md deleted file mode 100644 index 561997c..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-6-client-extras.md +++ /dev/null @@ -1,815 +0,0 @@ -# Phase 6: Client Extras — Implementation Plan - -**Date**: 2026-03-21 -**Prerequisites**: Phase 5 complete and passing (Client Core — `ILmxProxyClient`, `LmxProxyClient` partial classes, `ClientMetrics`, `ISubscription`, `ApiKeyInfo` all functional with unit tests passing) -**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy` - -## Guardrails - -1. **Client targets .NET 10, AnyCPU** — latest C# features permitted. -2. **Polly v8 API** — `ResiliencePipeline`, `ResiliencePipelineBuilder`, `RetryStrategyOptions`. Do NOT use Polly v7 `IAsyncPolicy`, `Policy.Handle<>().WaitAndRetryAsync(...)`. -3. **Builder default port is 50051** (per design doc section 11 — resolved conflict). -4. **No new NuGet packages** — `Polly 8.5.2`, `Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Binder 10.0.0`, `Microsoft.Extensions.Logging.Abstractions 10.0.0` are already in the csproj. -5. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client` -6. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests` - -## Step 1: LmxProxyClientBuilder - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs` - -Rewrite the builder for v2. Key changes from v1: -- Default port changes from `5050` to `50051` -- Retry uses Polly v8 `ResiliencePipeline` (built in `SetBuilderConfiguration`) -- `WithCorrelationIdHeader` support - -### 1.1 Builder fields - -```csharp -public class LmxProxyClientBuilder -{ - private string? _host; - private int _port = 50051; // CHANGED from 5050 - private string? _apiKey; - private ILogger? _logger; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private int _maxRetryAttempts = 3; - private TimeSpan _retryDelay = TimeSpan.FromSeconds(1); - private bool _enableMetrics; - private string? _correlationIdHeader; - private ClientTlsConfiguration? _tlsConfiguration; -``` - -### 1.2 Fluent methods - -Each method returns `this` for chaining. Validation at call site: - -| Method | Default | Validation | -|---|---|---| -| `WithHost(string host)` | Required | `!string.IsNullOrWhiteSpace(host)` | -| `WithPort(int port)` | 50051 | 1-65535 | -| `WithApiKey(string? apiKey)` | null | none | -| `WithLogger(ILogger logger)` | NullLogger | `!= null` | -| `WithTimeout(TimeSpan timeout)` | 30s | `> TimeSpan.Zero && <= TimeSpan.FromMinutes(10)` | -| `WithSslCredentials(string? certificatePath)` | disabled | creates/updates `_tlsConfiguration` with `UseTls=true` | -| `WithTlsConfiguration(ClientTlsConfiguration config)` | null | `!= null` | -| `WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)` | 3, 1s | `maxAttempts > 0`, `retryDelay > TimeSpan.Zero` | -| `WithMetrics()` | disabled | sets `_enableMetrics = true` | -| `WithCorrelationIdHeader(string headerName)` | null | `!string.IsNullOrEmpty` | - -### 1.3 Build() - -```csharp -public LmxProxyClient Build() -{ - if (string.IsNullOrWhiteSpace(_host)) - throw new InvalidOperationException("Host must be specified. Call WithHost() before Build()."); - - ValidateTlsConfiguration(); - - var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger) - { - DefaultTimeout = _defaultTimeout - }; - - client.SetBuilderConfiguration(new ClientConfiguration - { - MaxRetryAttempts = _maxRetryAttempts, - RetryDelay = _retryDelay, - EnableMetrics = _enableMetrics, - CorrelationIdHeader = _correlationIdHeader - }); - - return client; -} -``` - -### 1.4 ValidateTlsConfiguration - -If `_tlsConfiguration?.UseTls == true`: -- If `ServerCaCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`. -- If `ClientCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`. -- If `ClientKeyPath` is set and file doesn't exist → throw `FileNotFoundException`. - -### 1.5 Polly v8 ResiliencePipeline setup (in LmxProxyClient.SetBuilderConfiguration) - -This was defined in Step 4 of Phase 5. Verify it uses: - -```csharp -using Polly; -using Polly.Retry; -using Grpc.Core; - -_resiliencePipeline = new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = config.MaxRetryAttempts, - Delay = config.RetryDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder() - .Handle(ex => - ex.StatusCode == StatusCode.Unavailable || - ex.StatusCode == StatusCode.DeadlineExceeded || - ex.StatusCode == StatusCode.ResourceExhausted || - ex.StatusCode == StatusCode.Aborted), - OnRetry = args => - { - _logger.LogWarning( - "Retry {Attempt}/{Max} after {Delay}ms — {Error}", - args.AttemptNumber, config.MaxRetryAttempts, - args.RetryDelay.TotalMilliseconds, - args.Outcome.Exception?.Message ?? "unknown"); - return ValueTask.CompletedTask; - } - }) - .Build(); -``` - -Backoff sequence: `retryDelay * 2^(attempt-1)` → 1s, 2s, 4s for defaults. - -### 1.6 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 2: ClientConfiguration - -**File**: This is already defined in `LmxProxyClientBuilder.cs` (at the bottom of the file, as an `internal class`). Verify it contains: - -```csharp -internal class ClientConfiguration -{ - public int MaxRetryAttempts { get; set; } - public TimeSpan RetryDelay { get; set; } - public bool EnableMetrics { get; set; } - public string? CorrelationIdHeader { get; set; } -} -``` - -No changes needed if it matches. - -## Step 3: ILmxProxyClientFactory + LmxProxyClientFactory - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs` - -### 3.1 Interface - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client; - -public interface ILmxProxyClientFactory -{ - LmxProxyClient CreateClient(); - LmxProxyClient CreateClient(string configName); - LmxProxyClient CreateClient(Action builderAction); -} -``` - -### 3.2 Implementation - -```csharp -public class LmxProxyClientFactory : ILmxProxyClientFactory -{ - private readonly IConfiguration _configuration; - - public LmxProxyClientFactory(IConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - public LmxProxyClient CreateClient() => CreateClient("LmxProxy"); - - public LmxProxyClient CreateClient(string configName) - { - IConfigurationSection section = _configuration.GetSection(configName); - var options = new LmxProxyClientOptions(); - section.Bind(options); - return BuildFromOptions(options); - } - - public LmxProxyClient CreateClient(Action builderAction) - { - var builder = new LmxProxyClientBuilder(); - builderAction(builder); - return builder.Build(); - } - - private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options) - { - var builder = new LmxProxyClientBuilder() - .WithHost(options.Host) - .WithPort(options.Port) - .WithTimeout(options.Timeout) - .WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay); - - if (!string.IsNullOrEmpty(options.ApiKey)) - builder.WithApiKey(options.ApiKey); - - if (options.EnableMetrics) - builder.WithMetrics(); - - if (!string.IsNullOrEmpty(options.CorrelationIdHeader)) - builder.WithCorrelationIdHeader(options.CorrelationIdHeader); - - if (options.UseSsl) - { - builder.WithTlsConfiguration(new ClientTlsConfiguration - { - UseTls = true, - ServerCaCertificatePath = options.CertificatePath - }); - } - - return builder.Build(); - } -} -``` - -### 3.3 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 4: ServiceCollectionExtensions - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs` - -### 4.1 Options classes - -Define at the bottom of the file or in a separate `LmxProxyClientOptions.cs`: - -```csharp -public class LmxProxyClientOptions -{ - public string Host { get; set; } = "localhost"; - public int Port { get; set; } = 50051; // CHANGED from 5050 - public string? ApiKey { get; set; } - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); - public bool UseSsl { get; set; } - public string? CertificatePath { get; set; } - public bool EnableMetrics { get; set; } - public string? CorrelationIdHeader { get; set; } - public RetryOptions Retry { get; set; } = new(); -} - -public class RetryOptions -{ - public int MaxAttempts { get; set; } = 3; - public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1); -} -``` - -### 4.2 Extension methods - -```csharp -public static class ServiceCollectionExtensions -{ - /// Registers a singleton ILmxProxyClient from the "LmxProxy" config section. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, IConfiguration configuration) - { - return services.AddLmxProxyClient(configuration, "LmxProxy"); - } - - /// Registers a singleton ILmxProxyClient from a named config section. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, IConfiguration configuration, string sectionName) - { - services.AddSingleton( - sp => new LmxProxyClientFactory(configuration)); - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return factory.CreateClient(sectionName); - }); - return services; - } - - /// Registers a singleton ILmxProxyClient via builder action. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, Action configure) - { - services.AddSingleton(sp => - { - var builder = new LmxProxyClientBuilder(); - configure(builder); - return builder.Build(); - }); - return services; - } - - /// Registers a scoped ILmxProxyClient from the "LmxProxy" config section. - public static IServiceCollection AddScopedLmxProxyClient( - this IServiceCollection services, IConfiguration configuration) - { - services.AddSingleton( - sp => new LmxProxyClientFactory(configuration)); - services.AddScoped(sp => - { - var factory = sp.GetRequiredService(); - return factory.CreateClient(); - }); - return services; - } - - /// Registers a keyed singleton ILmxProxyClient. - public static IServiceCollection AddNamedLmxProxyClient( - this IServiceCollection services, string name, Action configure) - { - services.AddKeyedSingleton(name, (sp, key) => - { - var builder = new LmxProxyClientBuilder(); - configure(builder); - return builder.Build(); - }); - return services; - } -} -``` - -### 4.3 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 5: StreamingExtensions - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs` - -### 5.1 ReadStreamAsync - -```csharp -public static class StreamingExtensions -{ - /// - /// Reads multiple tags as an async stream in batches. - /// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors. - /// - public static async IAsyncEnumerable> ReadStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - int batchSize = 100, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize)); - - var batch = new List(batchSize); - int consecutiveErrors = 0; - const int maxConsecutiveErrors = 3; - const int maxRetries = 2; - - foreach (string address in addresses) - { - cancellationToken.ThrowIfCancellationRequested(); - batch.Add(address); - - if (batch.Count >= batchSize) - { - await foreach (var kvp in ReadBatchWithRetry( - client, batch, maxRetries, cancellationToken)) - { - consecutiveErrors = 0; - yield return kvp; - } - // If we get here without yielding, it was an error - // (handled inside ReadBatchWithRetry) - batch.Clear(); - } - } - - // Process remaining - if (batch.Count > 0) - { - await foreach (var kvp in ReadBatchWithRetry( - client, batch, maxRetries, cancellationToken)) - { - yield return kvp; - } - } - } - - private static async IAsyncEnumerable> ReadBatchWithRetry( - ILmxProxyClient client, - List batch, - int maxRetries, - [EnumeratorCancellation] CancellationToken ct) - { - int retries = 0; - while (retries <= maxRetries) - { - IDictionary? results = null; - try - { - results = await client.ReadBatchAsync(batch, ct); - } - catch when (retries < maxRetries) - { - retries++; - continue; - } - - if (results is not null) - { - foreach (var kvp in results) - yield return kvp; - yield break; - } - retries++; - } - } -``` - -### 5.2 WriteStreamAsync - -```csharp - /// - /// Writes values from an async enumerable in batches. Returns total count written. - /// - public static async Task WriteStreamAsync( - this ILmxProxyClient client, - IAsyncEnumerable> values, - int batchSize = 100, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(values); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize)); - - var batch = new Dictionary(batchSize); - int totalWritten = 0; - - await foreach (var kvp in values.WithCancellation(cancellationToken)) - { - batch[kvp.Key] = kvp.Value; - - if (batch.Count >= batchSize) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - batch.Clear(); - } - } - - if (batch.Count > 0) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - } - - return totalWritten; - } -``` - -### 5.3 ProcessInParallelAsync - -```csharp - /// - /// Processes items in parallel with a configurable max concurrency (default 4). - /// - public static async Task ProcessInParallelAsync( - this IAsyncEnumerable source, - Func processor, - int maxConcurrency = 4, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(processor); - if (maxConcurrency <= 0) - throw new ArgumentOutOfRangeException(nameof(maxConcurrency)); - - using var semaphore = new SemaphoreSlim(maxConcurrency); - var tasks = new List(); - - await foreach (T item in source.WithCancellation(cancellationToken)) - { - await semaphore.WaitAsync(cancellationToken); - - tasks.Add(Task.Run(async () => - { - try - { - await processor(item, cancellationToken); - } - finally - { - semaphore.Release(); - } - }, cancellationToken)); - } - - await Task.WhenAll(tasks); - } -``` - -### 5.4 SubscribeStreamAsync - -```csharp - /// - /// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels. - /// - public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - - var channel = Channel.CreateBounded<(string, Vtq)>( - new BoundedChannelOptions(1000) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false - }); - - ISubscription? subscription = null; - try - { - subscription = await client.SubscribeAsync( - addresses, - (tag, vtq) => - { - channel.Writer.TryWrite((tag, vtq)); - }, - ex => - { - channel.Writer.TryComplete(ex); - }, - cancellationToken); - - await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) - { - yield return item; - } - } - finally - { - subscription?.Dispose(); - channel.Writer.TryComplete(); - } - } -} -``` - -### 5.5 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 6: Properties/AssemblyInfo.cs - -**File**: `src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs` - -Create this file if it doesn't already exist: - -```csharp -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")] -``` - -This allows the test project to access `internal` types like `ClientMetrics` and `ClientConfiguration`. - -### 6.1 Verify build - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client" -``` - -## Step 7: Unit Tests - -Add tests to the existing `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` project (created in Phase 5). - -### 7.1 Builder Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs` - -```csharp -public class LmxProxyClientBuilderTests -{ - [Fact] - public void Build_ThrowsWhenHostNotSet() - { - var builder = new LmxProxyClientBuilder(); - Assert.Throws(() => builder.Build()); - } - - [Fact] - public void Build_DefaultPort_Is50051() - { - var client = new LmxProxyClientBuilder() - .WithHost("localhost") - .Build(); - // Verify via reflection or by checking connection attempt URI - Assert.NotNull(client); - } - - [Fact] - public void WithPort_ThrowsOnZero() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithPort(0)); - } - - [Fact] - public void WithPort_ThrowsOn65536() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithPort(65536)); - } - - [Fact] - public void WithTimeout_ThrowsOnNegative() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1))); - } - - [Fact] - public void WithTimeout_ThrowsOver10Minutes() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11))); - } - - [Fact] - public void WithRetryPolicy_ThrowsOnZeroAttempts() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1))); - } - - [Fact] - public void WithRetryPolicy_ThrowsOnZeroDelay() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero)); - } - - [Fact] - public void Build_WithAllOptions_Succeeds() - { - var client = new LmxProxyClientBuilder() - .WithHost("10.100.0.48") - .WithPort(50051) - .WithApiKey("test-key") - .WithTimeout(TimeSpan.FromSeconds(15)) - .WithRetryPolicy(5, TimeSpan.FromSeconds(2)) - .WithMetrics() - .WithCorrelationIdHeader("X-Correlation-ID") - .Build(); - Assert.NotNull(client); - } - - [Fact] - public void Build_WithTls_ValidatesCertificatePaths() - { - var builder = new LmxProxyClientBuilder() - .WithHost("localhost") - .WithTlsConfiguration(new ClientTlsConfiguration - { - UseTls = true, - ServerCaCertificatePath = "/nonexistent/cert.pem" - }); - Assert.Throws(() => builder.Build()); - } - - [Fact] - public void WithHost_ThrowsOnNull() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithHost(null!)); - } - - [Fact] - public void WithHost_ThrowsOnEmpty() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithHost("")); - } -} -``` - -### 7.2 Factory Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs` - -```csharp -public class LmxProxyClientFactoryTests -{ - [Fact] - public void CreateClient_BindsFromConfiguration() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["LmxProxy:Host"] = "10.100.0.48", - ["LmxProxy:Port"] = "50052", - ["LmxProxy:ApiKey"] = "test-key", - ["LmxProxy:Retry:MaxAttempts"] = "5", - ["LmxProxy:Retry:Delay"] = "00:00:02", - }) - .Build(); - - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient(); - Assert.NotNull(client); - } - - [Fact] - public void CreateClient_NamedSection() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["MyProxy:Host"] = "10.100.0.48", - ["MyProxy:Port"] = "50052", - }) - .Build(); - - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient("MyProxy"); - Assert.NotNull(client); - } - - [Fact] - public void CreateClient_BuilderAction() - { - var config = new ConfigurationBuilder().Build(); - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051)); - Assert.NotNull(client); - } -} -``` - -### 7.3 StreamingExtensions Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs` - -```csharp -public class StreamingExtensionsTests -{ - [Fact] - public async Task ReadStreamAsync_BatchesCorrectly() - // Create mock client, provide 250 addresses with batchSize=100 - // Verify ReadBatchAsync called 3 times (100, 100, 50) - - [Fact] - public async Task ReadStreamAsync_RetriesOnError() - // Mock first ReadBatchAsync to throw, second to succeed - // Verify results returned from second attempt - - [Fact] - public async Task WriteStreamAsync_BatchesAndReturnsCount() - // Provide async enumerable of 250 items, batchSize=100 - // Verify WriteBatchAsync called 3 times, total returned = 250 - - [Fact] - public async Task ProcessInParallelAsync_RespectsMaxConcurrency() - // Track concurrent count with SemaphoreSlim - // maxConcurrency=2, verify never exceeds 2 concurrent calls - - [Fact] - public async Task SubscribeStreamAsync_YieldsFromChannel() - // Mock SubscribeAsync to invoke onUpdate callback with test values - // Verify IAsyncEnumerable yields matching items -} -``` - -### 7.4 Run all tests - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal" -``` - -## Step 8: Build Verification - -Run full solution build and all tests: - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal" -``` - -## Completion Criteria - -- [ ] `LmxProxyClientBuilder` with default port 50051, Polly v8 wiring, all fluent methods, TLS validation -- [ ] `ClientConfiguration` internal record with retry, metrics, correlation header fields -- [ ] `ILmxProxyClientFactory` + `LmxProxyClientFactory` with 3 `CreateClient` overloads -- [ ] `ServiceCollectionExtensions` with `AddLmxProxyClient` (3 overloads), `AddScopedLmxProxyClient`, `AddNamedLmxProxyClient` -- [ ] `LmxProxyClientOptions` + `RetryOptions` configuration classes -- [ ] `StreamingExtensions` with `ReadStreamAsync` (batched, 2 retries, 3 consecutive error abort), `WriteStreamAsync` (batched), `ProcessInParallelAsync` (SemaphoreSlim, max 4), `SubscribeStreamAsync` (Channel-based IAsyncEnumerable) -- [ ] `Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for test project -- [ ] Builder tests: validation, defaults, Polly pipeline wiring, TLS cert validation -- [ ] Factory tests: config binding from IConfiguration, named sections, builder action -- [ ] StreamingExtensions tests: batching, error recovery, parallel throttling, subscription streaming -- [ ] Solution builds cleanly -- [ ] All tests pass diff --git a/deprecated/lmxproxy/docs/plans/phase-7-integration-deployment.md b/deprecated/lmxproxy/docs/plans/phase-7-integration-deployment.md deleted file mode 100644 index 25ceac5..0000000 --- a/deprecated/lmxproxy/docs/plans/phase-7-integration-deployment.md +++ /dev/null @@ -1,837 +0,0 @@ -# Phase 7: Integration Tests & Deployment — Implementation Plan - -**Date**: 2026-03-21 -**Prerequisites**: Phase 4 (Host complete) and Phase 6 (Client complete) both passing. All unit tests green. -**Working Directory (Mac)**: `/Users/dohertj2/Desktop/scadalink-design/lmxproxy` -**Working Directory (windev)**: `C:\src\lmxproxy` -**windev SSH**: `ssh windev` (alias configured in `~/.ssh/config`, passwordless ed25519, user `dohertj2`) - -## Guardrails - -1. **Never stop the v1 service until v2 is verified** — deploy v2 on alternate ports first. -2. **Take a Veeam backup before cutover** — provides rollback point. -3. **Integration tests run from Mac against windev** — they use `Grpc.Net.Client` which is cross-platform. -4. **All integration tests must pass before cutover**. -5. **API keys**: The existing `apikeys.json` on windev is the source of truth for valid keys. Read it to get test keys. -6. **Real MxAccess tags**: Use the `TestChildObject` tags on windev's AVEVA System Platform instance. Available tags cover all TypedValue cases: - - `TestChildObject.TestBool` (bool) - - `TestChildObject.TestInt` (int) - - `TestChildObject.TestFloat` (float) - - `TestChildObject.TestDouble` (double) - - `TestChildObject.TestString` (string) - - `TestChildObject.TestDateTime` (datetime) - - `TestChildObject.TestBoolArray[]` (bool array) - - `TestChildObject.TestDateTimeArray[]` (datetime array) - - `TestChildObject.TestDoubleArray[]` (double array) - - `TestChildObject.TestFloatArray[]` (float array) - - `TestChildObject.TestIntArray[]` (int array) - - `TestChildObject.TestStringArray[]` (string array) - -## Step 1: Build Host on windev - -### 1.1 Pull latest code - -```bash -ssh windev "cd C:\src\lmxproxy && git pull" -``` - -If the repo doesn't exist on windev yet: - -```bash -ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy" -``` - -### 1.2 Publish Host binary - -```bash -ssh windev "cd C:\src\lmxproxy && dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2\" -``` - -**Expected output**: `C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe` plus dependencies. - -### 1.3 Create v2 appsettings.json - -Create `C:\publish-v2\appsettings.json` configured for testing on alternate ports: - -```bash -ssh windev "powershell -Command \"@' -{ - \"GrpcPort\": 50052, - \"ApiKeyConfigFile\": \"apikeys.json\", - \"Connection\": { - \"MonitorIntervalSeconds\": 5, - \"ConnectionTimeoutSeconds\": 30, - \"ReadTimeoutSeconds\": 5, - \"WriteTimeoutSeconds\": 5, - \"MaxConcurrentOperations\": 10, - \"AutoReconnect\": true - }, - \"Subscription\": { - \"ChannelCapacity\": 1000, - \"ChannelFullMode\": \"DropOldest\" - }, - \"HealthCheck\": { - \"Enabled\": true, - \"TestTagAddress\": \"TestChildObject.TestBool\", - \"MaxStaleDataMinutes\": 5 - }, - \"Tls\": { - \"Enabled\": false - }, - \"WebServer\": { - \"Enabled\": true, - \"Port\": 8081 - }, - \"Serilog\": { - \"MinimumLevel\": { - \"Default\": \"Information\", - \"Override\": { - \"Microsoft\": \"Warning\", - \"System\": \"Warning\", - \"Grpc\": \"Information\" - } - }, - \"WriteTo\": [ - { \"Name\": \"Console\" }, - { - \"Name\": \"File\", - \"Args\": { - \"path\": \"logs/lmxproxy-v2-.txt\", - \"rollingInterval\": \"Day\", - \"retainedFileCountLimit\": 30 - } - } - ] - } -} -'@ | Set-Content -Path 'C:\publish-v2\appsettings.json' -Encoding UTF8\"" -``` - -**Key differences from production config**: gRPC port is 50052 (not 50051), web port is 8081 (not 8080), log file prefix is `lmxproxy-v2-`. - -### 1.4 Copy apikeys.json - -If v2 should use the same API keys as v1: - -```bash -ssh windev "copy C:\publish\apikeys.json C:\publish-v2\apikeys.json" -``` - -If `C:\publish\apikeys.json` doesn't exist (the v2 service will auto-generate one on first start): - -```bash -ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate" -``` - -### 1.5 Verify the publish directory - -```bash -ssh windev "dir C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe && dir C:\publish-v2\appsettings.json" -``` - -## Step 2: Deploy v2 Host Service - -### 2.1 Install as a separate Topshelf service - -The v2 service runs alongside v1 on different ports. Install with a distinct service name: - -```bash -ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\" -displayname \"SCADA Bridge LMX Proxy V2\" -description \"LmxProxy v2 gRPC service (test deployment)\" --autostart" -``` - -### 2.2 Start the v2 service - -```bash -ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2" -``` - -### 2.3 Wait 10 seconds for startup, then verify - -```bash -ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host.V2" -``` - -Expected: `STATE: 4 RUNNING`. - -### 2.4 Verify status page - -From Mac, use curl to check the v2 status page: - -```bash -curl -s http://10.100.0.48:8081/ | head -20 -``` - -Expected: HTML containing "LmxProxy Status Dashboard". - -```bash -curl -s http://10.100.0.48:8081/api/health -``` - -Expected: `OK` with HTTP 200. - -```bash -curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool | head -30 -``` - -Expected: JSON with `serviceName`, `connection.isConnected: true`, version info. - -### 2.5 Verify MxAccess connected - -The status page should show `MxAccess Connection: Connected`. If it shows `Disconnected`, check the logs: - -```bash -ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\"" -``` - -### 2.6 Read the apikeys.json to get test keys - -```bash -ssh windev "type C:\publish-v2\apikeys.json" -``` - -Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure: - -```json -{ - "Keys": [ - { "Key": "abc123...", "Role": "ReadWrite", "Description": "Default ReadWrite key" }, - { "Key": "def456...", "Role": "ReadOnly", "Description": "Default ReadOnly key" } - ] -} -``` - -## Step 3: Create Integration Test Project - -### 3.1 Create project - -On windev (or Mac — the test project is .NET 10 and cross-platform): - -```bash -cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy -dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.IntegrationTests -o tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --framework net10.0 -``` - -### 3.2 Configure csproj - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj` - -```xml - - - - net10.0 - latest - enable - false - - - - - - - - - - - - - - - - - PreserveNewest - - - - -``` - -### 3.3 Add to solution - -Edit `ZB.MOM.WW.LmxProxy.slnx`: - -```xml - - - - - - - - - - - -``` - -### 3.4 Create test configuration - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json` - -```json -{ - "LmxProxy": { - "Host": "10.100.0.48", - "Port": 50052, - "ReadWriteApiKey": "REPLACE_WITH_ACTUAL_KEY", - "ReadOnlyApiKey": "REPLACE_WITH_ACTUAL_KEY", - "InvalidApiKey": "invalid-key-that-does-not-exist" - } -} -``` - -**IMPORTANT**: After reading the actual `apikeys.json` from windev in Step 2.6, replace the placeholder values with the real keys. - -### 3.5 Create test base class - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs` - -```csharp -using Microsoft.Extensions.Configuration; -using ZB.MOM.WW.LmxProxy.Client; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public abstract class IntegrationTestBase : IAsyncLifetime -{ - protected IConfiguration Configuration { get; } - protected string Host { get; } - protected int Port { get; } - protected string ReadWriteApiKey { get; } - protected string ReadOnlyApiKey { get; } - protected string InvalidApiKey { get; } - protected LmxProxyClient? Client { get; set; } - - protected IntegrationTestBase() - { - Configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.test.json") - .Build(); - - var section = Configuration.GetSection("LmxProxy"); - Host = section["Host"] ?? "10.100.0.48"; - Port = int.Parse(section["Port"] ?? "50052"); - ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured"); - ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured"); - InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key"; - } - - protected LmxProxyClient CreateClient(string? apiKey = null) - { - return new LmxProxyClientBuilder() - .WithHost(Host) - .WithPort(Port) - .WithApiKey(apiKey ?? ReadWriteApiKey) - .WithTimeout(TimeSpan.FromSeconds(10)) - .WithRetryPolicy(2, TimeSpan.FromSeconds(1)) - .WithMetrics() - .Build(); - } - - public virtual async Task InitializeAsync() - { - Client = CreateClient(); - await Client.ConnectAsync(); - } - - public virtual async Task DisposeAsync() - { - if (Client is not null) - { - await Client.DisconnectAsync(); - Client.Dispose(); - } - } -} -``` - -## Step 4: Integration Test Scenarios - -### 4.1 Connection Lifecycle - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class ConnectionTests : IntegrationTestBase -{ - [Fact] - public async Task ConnectAndDisconnect_Succeeds() - { - // Client is connected in InitializeAsync - Assert.True(await Client!.IsConnectedAsync()); - await Client.DisconnectAsync(); - Assert.False(await Client.IsConnectedAsync()); - } - - [Fact] - public async Task ConnectWithInvalidApiKey_Fails() - { - using var badClient = CreateClient(InvalidApiKey); - // Expect RpcException with StatusCode.Unauthenticated - var ex = await Assert.ThrowsAsync( - () => badClient.ConnectAsync()); - Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode); - } - - [Fact] - public async Task DoubleConnect_IsIdempotent() - { - await Client!.ConnectAsync(); // Already connected — should be no-op - Assert.True(await Client.IsConnectedAsync()); - } -} -``` - -### 4.2 Read Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class ReadTests : IntegrationTestBase -{ - [Fact] - public async Task Read_BoolTag_ReturnsBoolValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestBool"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_IntTag_ReturnsIntValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestInt"); - Assert.True(vtq.Value is int or long); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_FloatTag_ReturnsFloatValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestFloat"); - Assert.True(vtq.Value is float or double); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_DoubleTag_ReturnsDoubleValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestDouble"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_StringTag_ReturnsStringValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestString"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_DateTimeTag_ReturnsDateTimeValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestDateTime"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1)); - } - - [Fact] - public async Task ReadBatch_MultiplesTags_ReturnsDictionary() - { - var tags = new[] { "TestChildObject.TestString", "TestChildObject.TestString" }; - var results = await Client!.ReadBatchAsync(tags); - Assert.Equal(2, results.Count); - Assert.True(results.ContainsKey("TestChildObject.TestString")); - Assert.True(results.ContainsKey("TestChildObject.TestString")); - } - - [Fact] - public async Task Read_NonexistentTag_ReturnsBadQuality() - { - // Reading a tag that doesn't exist should return Bad quality - // (or throw — depends on Host implementation. Adjust assertion accordingly.) - var vtq = await Client!.ReadAsync("NonExistent.Tag.12345"); - // If the Host returns success=false, ReadAsync will throw. - // If it returns success=true with bad quality, check quality. - // Adjust based on actual behavior. - } -} -``` - -### 4.3 Write Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs` - -```csharp -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class WriteTests : IntegrationTestBase -{ - [Fact] - public async Task WriteAndReadBack_StringValue() - { - string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}"; - // Write to a writable string tag - await Client!.WriteAsync("TestChildObject.TestString", - new TypedValue { StringValue = testValue }); - - // Read back and verify - await Task.Delay(500); // Allow time for write to propagate - var vtq = await Client.ReadAsync("TestChildObject.TestString"); - Assert.Equal(testValue, vtq.Value); - } - - [Fact] - public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied() - { - using var readOnlyClient = CreateClient(ReadOnlyApiKey); - await readOnlyClient.ConnectAsync(); - - var ex = await Assert.ThrowsAsync( - () => readOnlyClient.WriteAsync("TestChildObject.TestString", - new TypedValue { StringValue = "should-fail" })); - Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode); - } -} -``` - -### 4.4 Subscribe Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class SubscribeTests : IntegrationTestBase -{ - [Fact] - public async Task Subscribe_ReceivesUpdates() - { - var received = new List<(string Tag, Vtq Vtq)>(); - var receivedEvent = new TaskCompletionSource(); - - var subscription = await Client!.SubscribeAsync( - new[] { "TestChildObject.TestInt" }, - (tag, vtq) => - { - received.Add((tag, vtq)); - if (received.Count >= 3) - receivedEvent.TrySetResult(true); - }, - ex => receivedEvent.TrySetException(ex)); - - // Wait up to 30 seconds for at least 3 updates - var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30))); - subscription.Dispose(); - - Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}"); - - // Verify the VTQ has correct structure - var first = received[0]; - Assert.Equal("TestChildObject.TestInt", first.Tag); - Assert.NotNull(first.Vtq.Value); - // ScanTime should be a DateTime value - Assert.True(first.Vtq.Timestamp > DateTime.MinValue); - } -} -``` - -### 4.5 WriteBatchAndWait Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs` - -```csharp -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class WriteBatchAndWaitTests : IntegrationTestBase -{ - [Fact] - public async Task WriteBatchAndWait_TypeAwareComparison() - { - // This test requires a writable tag and a flag tag. - // Adjust tag names based on available tags in TestChildObject. - // Example: write values and poll a flag. - - var values = new Dictionary - { - ["TestChildObject.TestString"] = new TypedValue { StringValue = "BatchTest" } - }; - - // Poll the same tag we wrote to (simple self-check) - var response = await Client!.WriteBatchAndWaitAsync( - values, - flagTag: "TestChildObject.TestString", - flagValue: new TypedValue { StringValue = "BatchTest" }, - timeoutMs: 5000, - pollIntervalMs: 200); - - Assert.True(response.Success); - Assert.True(response.FlagReached); - Assert.True(response.ElapsedMs < 5000); - } -} -``` - -### 4.6 CheckApiKey Tests - -**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs` - -```csharp -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class CheckApiKeyTests : IntegrationTestBase -{ - [Fact] - public async Task CheckApiKey_ValidReadWrite_ReturnsValid() - { - var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey); - Assert.True(info.IsValid); - } - - [Fact] - public async Task CheckApiKey_ValidReadOnly_ReturnsValid() - { - var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey); - Assert.True(info.IsValid); - } - - [Fact] - public async Task CheckApiKey_Invalid_ReturnsInvalid() - { - var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345"); - Assert.False(info.IsValid); - } -} -``` - -## Step 5: Run Integration Tests - -### 5.1 Build the test project (from Mac) - -```bash -cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy -dotnet build tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests -``` - -### 5.2 Run integration tests against v2 on alternate port - -```bash -dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal -``` - -All tests should pass against `10.100.0.48:50052`. - -### 5.3 Debug failures - -If tests fail, check: -1. v2 service is running: `ssh windev "sc query ZB.MOM.WW.LmxProxy.Host.V2"` -2. v2 service logs: `ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i error"` -3. Network connectivity: `curl -s http://10.100.0.48:8081/api/health` -4. API keys match: `ssh windev "type C:\publish-v2\apikeys.json"` - -### 5.4 Verify metrics after test run - -```bash -curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool -``` - -Should show non-zero operation counts for Read, ReadBatch, Write, etc. - -## Step 6: Cutover - -**Only proceed if ALL integration tests pass.** - -### 6.1 Stop v1 service - -```bash -ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host" -``` - -Verify stopped: - -```bash -ssh windev "sc query ZB.MOM.WW.LmxProxy.Host" -``` - -Expected: `STATE: 1 STOPPED`. - -### 6.2 Stop v2 service - -```bash -ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host.V2" -``` - -### 6.3 Reconfigure v2 to production ports - -Update `C:\publish-v2\appsettings.json`: -- Change `GrpcPort` from `50052` to `50051` -- Change `WebServer.Port` from `8081` to `8080` -- Change log file prefix from `lmxproxy-v2-` to `lmxproxy-` - -```bash -ssh windev "powershell -Command \"(Get-Content 'C:\publish-v2\appsettings.json') -replace '50052','50051' -replace '8081','8080' -replace 'lmxproxy-v2-','lmxproxy-' | Set-Content 'C:\publish-v2\appsettings.json'\"" -``` - -### 6.4 Uninstall v1 service - -```bash -ssh windev "C:\publish\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host\"" -``` - -### 6.5 Uninstall v2 test service and reinstall as production service - -```bash -ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\"" -``` - -```bash -ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host\" -displayname \"SCADA Bridge LMX Proxy\" -description \"LmxProxy v2 gRPC service\" --autostart" -``` - -### 6.6 Start the production service - -```bash -ssh windev "sc start ZB.MOM.WW.LmxProxy.Host" -``` - -### 6.7 Verify on production ports - -```bash -ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host" -``` - -Expected: `STATE: 4 RUNNING`. - -```bash -curl -s http://10.100.0.48:8080/api/health -``` - -Expected: `OK`. - -```bash -curl -s http://10.100.0.48:8080/api/status | python3 -m json.tool | head -15 -``` - -Expected: Connected, version shows v2. - -### 6.8 Update test configuration and re-run integration tests - -Update `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json`: -- Change `Port` from `50052` to `50051` - -```bash -dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal -``` - -All tests should pass on the production port. - -### 6.9 Configure service recovery - -```bash -ssh windev "sc failure ZB.MOM.WW.LmxProxy.Host reset= 86400 actions= restart/60000/restart/300000/restart/600000" -``` - -This configures: restart after 1 min on first failure, 5 min on second, 10 min on subsequent. Reset counter after 1 day (86400 seconds). - -## Step 7: Documentation Updates - -### 7.1 Update windev.md - -Add a section about the LmxProxy v2 service to `/Users/dohertj2/Desktop/scadalink-design/windev.md`: - -```markdown -## LmxProxy v2 - -| Field | Value | -|---|---| -| Service Name | ZB.MOM.WW.LmxProxy.Host | -| Display Name | SCADA Bridge LMX Proxy | -| gRPC Port | 50051 | -| Status Page | http://10.100.0.48:8080/ | -| Health Endpoint | http://10.100.0.48:8080/api/health | -| Publish Directory | C:\publish-v2\ | -| API Keys | C:\publish-v2\apikeys.json | -| Logs | C:\publish-v2\logs\ | -| Protocol | v2 (TypedValue + QualityCode) | -``` - -### 7.2 Update lmxproxy CLAUDE.md - -If `lmxproxy/CLAUDE.md` references v1 behavior, update: -- Change "currently v1 protocol" references to "v2 protocol" -- Update publish directory references from `C:\publish\` to `C:\publish-v2\` -- Update any value conversion notes (no more string heuristics) - -### 7.3 Clean up v1 publish directory (optional) - -```bash -ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup" -``` - -## Step 8: Veeam Backup - -### 8.1 Take incremental backup - -```bash -ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\"" -``` - -### 8.2 Wait for backup to complete (check status) - -```bash -ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\"" -``` - -Expected: `State: Stopped, Result: Success`. - -### 8.3 Get the restore point ID - -```bash -ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object Id, CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\"" -``` - -### 8.4 Record in windev.md - -Add a new row to the Restore Points table in `windev.md`: - -```markdown -| `XXXXXXXX` | 2026-XX-XX XX:XX | Increment | **Post-v2 deployment** — LmxProxy v2 live on port 50051 | -``` - -Replace placeholders with actual restore point ID and timestamp. - -## Completion Criteria - -- [ ] v2 Host binary published to `C:\publish-v2\` on windev -- [ ] v2 service installed and running on alternate ports (50052/8081) — verified via status page -- [ ] Integration test project created at `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/` -- [ ] All integration tests pass against v2 on alternate ports: - - [ ] Connect/disconnect lifecycle - - [ ] Read string tag `TestChildObject.TestString` — value "JoeDev", Good quality - - [ ] Read writable tag `TestChildObject.TestString` - - [ ] Write string then read-back verification - - [ ] ReadBatch multiple tags - - [ ] Subscribe to `TestChildObject.TestInt` — verify updates received with TypedValue + QualityCode - - [ ] WriteBatchAndWait with type-aware flag comparison - - [ ] CheckApiKey — valid ReadWrite, valid ReadOnly, invalid - - [ ] Write with ReadOnly key — PermissionDenied - - [ ] Connect with invalid API key — Unauthenticated -- [ ] v1 service stopped and uninstalled -- [ ] v2 service reconfigured to production ports (50051/8080) and reinstalled -- [ ] All integration tests pass on production ports -- [ ] Service recovery configured (restart on failure) -- [ ] `windev.md` updated with v2 service details -- [ ] `lmxproxy/CLAUDE.md` updated for v2 -- [ ] Veeam backup taken and restore point ID recorded in `windev.md` -- [ ] v1 publish directory backed up or removed diff --git a/deprecated/lmxproxy/docs/requirements/Component-Client.md b/deprecated/lmxproxy/docs/requirements/Component-Client.md deleted file mode 100644 index 9a2e5a5..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-Client.md +++ /dev/null @@ -1,200 +0,0 @@ -# Component: Client - -## Purpose - -A .NET 10 class library providing a typed gRPC client for consuming the LmxProxy service. Used by ScadaLink's Data Connection Layer to connect to AVEVA System Platform via the LmxProxy Host. - -## Location - -`src/ZB.MOM.WW.LmxProxy.Client/` — all files in this project. - -Key files: -- `ILmxProxyClient.cs` — public interface. -- `LmxProxyClient.cs` — main implementation (partial class across multiple files). -- `LmxProxyClientBuilder.cs` — fluent builder for client construction. -- `ServiceCollectionExtensions.cs` — DI integration and options classes. -- `ILmxProxyClientFactory.cs` — factory interface and implementation. -- `StreamingExtensions.cs` — batch and parallel streaming helpers. -- `Domain/ScadaContracts.cs` — code-first gRPC contracts. -- `Security/GrpcChannelFactory.cs` — TLS channel creation. - -## Responsibilities - -- Connect to and communicate with the LmxProxy Host gRPC service. -- Manage session lifecycle (connect, keep-alive, disconnect). -- Execute read, write, and subscribe operations with retry and concurrency control. -- Provide a fluent builder and DI integration for configuration. -- Track client-side performance metrics. -- Support TLS and mutual TLS connections. - -## 1. Public Interface (ILmxProxyClient) - -| Method | Description | -|--------|-------------| -| `ConnectAsync(ct)` | Establish gRPC channel and session | -| `DisconnectAsync()` | Graceful disconnect | -| `IsConnectedAsync()` | Thread-safe connection state check | -| `ReadAsync(address, ct)` | Read single tag, returns Vtq | -| `ReadBatchAsync(addresses, ct)` | Read multiple tags, returns dictionary | -| `WriteAsync(address, value, ct)` | Write single tag value | -| `WriteBatchAsync(values, ct)` | Write multiple tag values | -| `SubscribeAsync(addresses, onUpdate, onStreamError, ct)` | Subscribe to tag updates with value and error callbacks | -| `GetMetrics()` | Return operation counts, errors, latency stats | -| `DefaultTimeout` | Configurable timeout (default 30s, range 1s–10min) | - -Implements `IDisposable` and `IAsyncDisposable`. - -## 2. Connection Management - -### 2.1 Connect - -`ConnectAsync()`: -1. Creates a gRPC channel via `GrpcChannelFactory` (HTTP or HTTPS based on TLS config). -2. Creates a `protobuf-net.Grpc` client for `IScadaService`. -3. Calls the `Connect` RPC with a client ID (format: `ScadaBridge-{guid}`) and optional API key. -4. Stores the returned session ID. -5. Starts the keep-alive timer. - -### 2.2 Keep-Alive - -- Timer-based ping every **30 seconds** (hardcoded). -- Sends a lightweight `GetConnectionState` RPC. -- On failure: stops the timer, marks disconnected, triggers subscription cleanup. - -### 2.3 Disconnect - -`DisconnectAsync()`: -1. Stops keep-alive timer. -2. Calls `Disconnect` RPC. -3. Clears session ID. -4. Disposes gRPC channel. - -### 2.4 Connection State - -`IsConnected` property: `!_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId)`. - -## 3. Builder Pattern (LmxProxyClientBuilder) - -| Method | Default | Constraint | -|--------|---------|-----------| -| `WithHost(string)` | Required | Non-null/non-empty | -| `WithPort(int)` | 5050 | 1–65535 | -| `WithApiKey(string?)` | null | Optional | -| `WithTimeout(TimeSpan)` | 30 seconds | > 0 and ≤ 10 minutes | -| `WithLogger(ILogger)` | NullLogger | Optional | -| `WithSslCredentials(string?)` | Disabled | Optional cert path | -| `WithTlsConfiguration(ClientTlsConfiguration)` | null | Full TLS config | -| `WithRetryPolicy(int, TimeSpan)` | 3 attempts, 1s delay | maxAttempts > 0, delay > 0 | -| `WithMetrics()` | Disabled | Enables metric collection | -| `WithCorrelationIdHeader(string)` | null | Custom header name | - -## 4. Retry Policy - -Polly-based exponential backoff: -- Default: **3 attempts** with **1-second** initial delay. -- Backoff sequence: `delay * 2^(retryAttempt - 1)` → 1s, 2s, 4s. -- Transient errors retried: `Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`. -- Each retry is logged with correlation ID at Warning level. - -## 5. Subscription - -### 5.1 Subscribe API - -`SubscribeAsync(addresses, onUpdate, onStreamError, ct)` returns an `ISubscription`: -- Calls the `Subscribe` RPC (server streaming) with the tag list and default sampling interval (**1000ms**). -- Processes streamed `VtqMessage` items asynchronously, invoking the `onUpdate(tag, vtq)` callback for each. -- On stream termination (server disconnect, gRPC error, or connection drop), invokes the `onStreamError` callback exactly once. -- On stream error, the client immediately nullifies its session ID, causing `IsConnected` to return `false`. This triggers the DCL adapter's `Disconnected` event and reconnection cycle. -- Errors are logged per-subscription. - -### 5.2 ISubscription - -- `Dispose()` — synchronous disposal with **5-second** timeout. -- Automatic callback on disposal for cleanup. - -## 6. DI Integration - -### 6.1 Service Collection Extensions - -| Method | Lifetime | Description | -|--------|----------|-------------| -| `AddLmxProxyClient(IConfiguration)` | Singleton | Bind `LmxProxy` config section | -| `AddLmxProxyClient(IConfiguration, string)` | Singleton | Bind named config section | -| `AddLmxProxyClient(Action)` | Singleton | Builder action | -| `AddScopedLmxProxyClient(IConfiguration)` | Scoped | Per-scope lifetime | -| `AddNamedLmxProxyClient(string, Action)` | Keyed singleton | Named/keyed registration | - -### 6.2 Configuration Options (LmxProxyClientOptions) - -Bound from `appsettings.json`: - -| Setting | Default | Description | -|---------|---------|-------------| -| Host | `localhost` | Server hostname | -| Port | 5050 | Server port | -| ApiKey | null | API key | -| Timeout | 30 seconds | Operation timeout | -| UseSsl | false | Enable TLS | -| CertificatePath | null | SSL certificate path | -| EnableMetrics | false | Enable client metrics | -| CorrelationIdHeader | null | Custom correlation header | -| Retry:MaxAttempts | 3 | Retry attempts | -| Retry:Delay | 1 second | Initial retry delay | - -### 6.3 Factory Pattern - -`ILmxProxyClientFactory` creates configured clients: -- `CreateClient()` — uses default `LmxProxy` config section. -- `CreateClient(string)` — uses named config section. -- `CreateClient(Action)` — uses builder action. - -Registered as singleton in DI. - -## 7. Streaming Extensions - -Helper methods for large-scale batch operations: - -| Method | Default Batch Size | Description | -|--------|--------------------|-------------| -| `ReadStreamAsync` | 100 | Batched reads, 2 retries per batch, stops after 3 consecutive errors. Returns `IAsyncEnumerable>`. | -| `WriteStreamAsync` | 100 | Batched writes from async enumerable input. Returns total count written. | -| `ProcessInParallelAsync` | — | Parallel processing with max concurrency of **4** (configurable). Semaphore-based rate limiting. | -| `SubscribeStreamAsync` | — | Wraps callback-based subscription into `IAsyncEnumerable` via `System.Threading.Channels`. | - -## 8. Client Metrics - -When metrics are enabled (`WithMetrics()`): -- Per-operation tracking: counts, error counts, latency. -- Rolling buffer of **1000** latency samples per operation (prevents memory growth). -- Snapshot via `GetMetrics()` returns: `{op}_count`, `{op}_errors`, `{op}_avg_latency_ms`, `{op}_p95_latency_ms`, `{op}_p99_latency_ms`. - -## 9. Value and Quality Handling - -### 9.1 Values (TypedValue) - -Read responses and subscription updates return values as `TypedValue` (protobuf oneof). The client extracts the value directly from the appropriate oneof field (e.g., `vtq.Value.DoubleValue`, `vtq.Value.BoolValue`). Write operations construct `TypedValue` with the correct oneof case for the value's native type. No string serialization or parsing is needed. - -### 9.2 Quality (QualityCode) - -Quality is received as a `QualityCode` message. Category checks use bitmask: `IsGood = (statusCode & 0xC0000000) == 0x00000000`, `IsBad = (statusCode & 0xC0000000) == 0x80000000`. The `symbolic_name` field provides human-readable quality for logging and display. - -### 9.3 Current Implementation (V1 Legacy) - -The current codebase still uses v1 string-based encoding. During v2 migration, the following will be removed: -- `ConvertToVtq()` — parses string values via heuristic (double → bool → null → raw string). -- `ConvertToString()` — serializes values via `.ToString()`. - -## Dependencies - -- **protobuf-net.Grpc** — code-first gRPC client. -- **Grpc.Net.Client** — HTTP/2 gRPC transport. -- **Polly** — retry policies. -- **Microsoft.Extensions.DependencyInjection** — DI integration. -- **Microsoft.Extensions.Configuration** — options binding. -- **Microsoft.Extensions.Logging** — logging abstraction. - -## Interactions - -- **ScadaLink Data Connection Layer** consumes the client library via `ILmxProxyClient`. -- **Protocol** — the client uses code-first contracts (`IScadaService`) that are wire-compatible with the Host's proto-generated service. -- **Security** — `GrpcChannelFactory` creates TLS-configured channels matching the Host's TLS configuration. diff --git a/deprecated/lmxproxy/docs/requirements/Component-Configuration.md b/deprecated/lmxproxy/docs/requirements/Component-Configuration.md deleted file mode 100644 index 66e2f07..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-Configuration.md +++ /dev/null @@ -1,122 +0,0 @@ -# Component: Configuration - -## Purpose - -Defines the `appsettings.json` structure, configuration binding, and startup validation for the LmxProxy Host service. - -## Location - -- `src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs` — root configuration class. -- `src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs` — validation logic. -- `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json` — default configuration file. - -## Responsibilities - -- Define all configurable settings as strongly-typed classes. -- Bind `appsettings.json` sections to configuration objects via `Microsoft.Extensions.Configuration`. -- Validate all settings at startup, failing fast on invalid values. -- Support environment variable overrides. - -## 1. Configuration Structure - -### 1.1 Root: LmxProxyConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| GrpcPort | int | 50051 | gRPC server listen port | -| ApiKeyConfigFile | string | `apikeys.json` | Path to API key configuration file | -| Subscription | SubscriptionConfiguration | — | Subscription channel settings | -| ServiceRecovery | ServiceRecoveryConfiguration | — | Windows SCM recovery settings | -| Connection | ConnectionConfiguration | — | MxAccess connection settings | -| Tls | TlsConfiguration | — | TLS/SSL settings | -| WebServer | WebServerConfiguration | — | Status web server settings | - -### 1.2 ConnectionConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| MonitorIntervalSeconds | int | 5 | Auto-reconnect check interval | -| ConnectionTimeoutSeconds | int | 30 | Initial connection timeout | -| ReadTimeoutSeconds | int | 5 | Per-read operation timeout | -| WriteTimeoutSeconds | int | 5 | Per-write operation timeout | -| MaxConcurrentOperations | int | 10 | Semaphore limit for concurrent MxAccess operations | -| AutoReconnect | bool | true | Enable auto-reconnect loop | -| NodeName | string? | null | MxAccess node name (optional) | -| GalaxyName | string? | null | MxAccess galaxy name (optional) | - -### 1.3 SubscriptionConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| ChannelCapacity | int | 1000 | Per-client subscription buffer size | -| ChannelFullMode | string | `DropOldest` | Backpressure strategy: `DropOldest`, `DropNewest`, `Wait` | - -### 1.4 TlsConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| Enabled | bool | false | Enable TLS on gRPC server | -| ServerCertificatePath | string | `certs/server.crt` | PEM server certificate | -| ServerKeyPath | string | `certs/server.key` | PEM server private key | -| ClientCaCertificatePath | string | `certs/ca.crt` | CA certificate for mTLS | -| RequireClientCertificate | bool | false | Require client certificates | -| CheckCertificateRevocation | bool | false | Enable CRL checking | - -### 1.5 WebServerConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| Enabled | bool | true | Enable status web server | -| Port | int | 8080 | HTTP listen port | -| Prefix | string? | null | Custom URL prefix (defaults to `http://+:{Port}/`) | - -### 1.6 ServiceRecoveryConfiguration - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| FirstFailureDelayMinutes | int | 1 | Restart delay after first failure | -| SecondFailureDelayMinutes | int | 5 | Restart delay after second failure | -| SubsequentFailureDelayMinutes | int | 10 | Restart delay after subsequent failures | -| ResetPeriodDays | int | 1 | Days before failure count resets | - -## 2. Validation - -`ConfigurationValidator.ValidateAndLog()` runs at startup and checks: - -- **GrpcPort**: Must be 1–65535. -- **Connection**: All timeout values > 0. NodeName and GalaxyName ≤ 255 characters. -- **Subscription**: ChannelCapacity 0–100000. ChannelFullMode must be one of `DropOldest`, `DropNewest`, `Wait`. -- **ServiceRecovery**: All failure delay values ≥ 0. ResetPeriodDays > 0. -- **TLS**: If enabled, validates certificate file paths exist. - -Validation errors are logged and cause the service to throw `InvalidOperationException`, preventing startup. - -## 3. Configuration Sources - -Configuration is loaded via `Microsoft.Extensions.Configuration.ConfigurationBuilder`: -1. `appsettings.json` (required). -2. Environment variables (override any JSON setting). - -## 4. Serilog Configuration - -Logging is configured in the `Serilog` section of `appsettings.json`: - -| Setting | Value | -|---------|-------| -| Console sink | ANSI theme, custom template with HH:mm:ss timestamp | -| File sink | `logs/lmxproxy-.txt`, daily rolling, 30 files retained | -| Default level | Information | -| Override: Microsoft | Warning | -| Override: System | Warning | -| Override: Grpc | Information | -| Enrichment | FromLogContext, WithMachineName, WithThreadId | - -## Dependencies - -- **Microsoft.Extensions.Configuration** — configuration binding. -- **Serilog.Settings.Configuration** — Serilog configuration from appsettings. - -## Interactions - -- **ServiceHost** (Program.cs) loads and validates configuration at startup. -- All other components receive their settings from the bound configuration objects. diff --git a/deprecated/lmxproxy/docs/requirements/Component-GrpcServer.md b/deprecated/lmxproxy/docs/requirements/Component-GrpcServer.md deleted file mode 100644 index 4032dba..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-GrpcServer.md +++ /dev/null @@ -1,86 +0,0 @@ -# Component: GrpcServer - -## Purpose - -The gRPC service implementation that receives client RPCs, validates sessions, and delegates operations to the MxAccessClient. It is the network-facing entry point for all SCADA operations. - -## Location - -`src/ZB.MOM.WW.LmxProxy.Host/Grpc/ScadaGrpcService.cs` — inherits proto-generated `ScadaService.ScadaServiceBase`. - -## Responsibilities - -- Implement all 10 gRPC RPCs defined in `scada.proto`. -- Validate session IDs on all data operations before processing. -- Delegate read/write/subscribe operations to the MxAccessClient. -- Convert between gRPC message types and internal domain types (Vtq, Quality). -- Track operation timing and success/failure via PerformanceMetrics. -- Handle errors gracefully, returning structured error responses rather than throwing. - -## 1. RPC Implementations - -### 1.1 Connection Management - -- **Connect**: Creates a new session via SessionManager if MxAccess is connected. Returns the session ID (32-character hex GUID). Rejects if MxAccess is disconnected. -- **Disconnect**: Terminates the session via SessionManager. -- **GetConnectionState**: Returns `IsConnected`, `ClientId`, and `ConnectedSinceUtcTicks` from the MxAccessClient. - -### 1.2 Read Operations - -- **Read**: Validates session, applies Polly retry policy, calls MxAccessClient.ReadAsync(), returns VtqMessage. On invalid session, returns a VtqMessage with `Quality.Bad`. -- **ReadBatch**: Validates session, reads all tags via MxAccessClient.ReadBatchAsync() with semaphore-controlled concurrency (max 10 concurrent). Returns results in request order. Batch reads are partially successful — individual tags may have Bad quality (with current UTC timestamp) while the overall response succeeds. If a tag read throws an exception, its VTQ is returned with Bad quality. - -### 1.3 Write Operations - -- **Write**: Validates session, parses the string value using the type heuristic, calls MxAccessClient.WriteAsync(). -- **WriteBatch**: Validates session, writes all items in parallel via MxAccessClient with semaphore concurrency control. Returns per-item success/failure results. Overall `success` is `false` if any item fails (all-or-nothing at the reporting level). -- **WriteBatchAndWait**: Validates session, writes all items first. If any write fails, returns immediately with `success=false`. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals using type-aware `TypedValueEquals()` comparison (same oneof case required, native type equality, case-sensitive strings, null equals null only). Default timeout: 5000ms, default poll interval: 100ms. If flag matches before timeout: `success=true`, `flag_reached=true`. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error). Returns `flag_reached` boolean and `elapsed_ms`. - -### 1.4 Subscription - -- **Subscribe**: Validates session (throws `RpcException(Unauthenticated)` on invalid). Creates a subscription handle via SubscriptionManager. Streams VtqMessage items from the subscription channel to the client. Cleans up the subscription on stream cancellation or error. - -### 1.5 API Key Check - -- **CheckApiKey**: Returns validity and role information from the interceptor context. - -## 2. Value and Quality Handling - -### 2.1 Values (TypedValue) - -Read responses and subscription updates return values as `TypedValue` (protobuf oneof carrying native types). Write requests receive `TypedValue` and apply the value directly to MxAccess by its native type. If the `oneof` case doesn't match the tag's expected data type, the write returns `WriteResult` with `success=false` indicating type mismatch. No string serialization or parsing heuristics are used. - -### 2.2 Quality (QualityCode) - -Quality is returned as a `QualityCode` message with `uint32 status_code` (OPC UA-compatible) and `string symbolic_name`. The server maps MxAccess quality codes to OPC UA status codes per the quality table in Component-Protocol. Specific error scenarios return specific quality codes (e.g., tag not found → `BadConfigurationError`, comms loss → `BadCommunicationFailure`). - -### 2.3 Current Implementation (V1 Legacy) - -The current codebase still uses v1 string-based encoding. During v2 migration, the following v1 behavior will be removed: -- `ConvertValueToString()` — serializes values to strings (bool → lowercase, DateTime → ISO-8601, arrays → JSON, others → `.ToString()`). -- `ParseValue()` — parses string values in order: bool → int → long → double → DateTime → raw string. -- Three-state string quality mapping: ≥192 → `"Good"`, 64–191 → `"Uncertain"`, <64 → `"Bad"`. - -## 3. Error Handling - -- All RPC methods catch exceptions and return error responses with `success=false` and a descriptive message. Exceptions do not propagate as gRPC status codes (except Subscribe, which throws `RpcException` for invalid sessions). -- Each operation is wrapped in a PerformanceMetrics timing scope that records duration and success/failure. - -## 4. Session Validation - -- All data operations (Read, ReadBatch, Write, WriteBatch, WriteBatchAndWait, Subscribe) validate the session ID before processing. -- Invalid session on read/write operations returns a response with Bad quality VTQ. -- Invalid session on Subscribe throws `RpcException` with `StatusCode.Unauthenticated`. - -## Dependencies - -- **MxAccessClient** (IScadaClient) — all SCADA operations are delegated here. -- **SessionManager** — session creation, validation, and termination. -- **SubscriptionManager** — subscription lifecycle for the Subscribe RPC. -- **PerformanceMetrics** — operation timing and success/failure tracking. - -## Interactions - -- **ApiKeyInterceptor** intercepts all RPCs before they reach ScadaGrpcService, enforcing API key authentication and role-based write authorization. -- **SubscriptionManager** provides the channel that Subscribe streams from. -- **StatusReportService** reads PerformanceMetrics data that ScadaGrpcService populates. diff --git a/deprecated/lmxproxy/docs/requirements/Component-HealthAndMetrics.md b/deprecated/lmxproxy/docs/requirements/Component-HealthAndMetrics.md deleted file mode 100644 index dd21ace..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-HealthAndMetrics.md +++ /dev/null @@ -1,121 +0,0 @@ -# Component: HealthAndMetrics - -## Purpose - -Provides health checking, performance metrics collection, and an HTTP status dashboard for monitoring the LmxProxy service. - -## Location - -- `src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs` — basic health check. -- `src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs` — detailed health check with test tag read. -- `src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs` — operation metrics collection. -- `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs` — status report generation. -- `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs` — HTTP status endpoint. - -## Responsibilities - -- Evaluate service health based on connection state, operation success rates, and test tag reads. -- Track per-operation performance metrics (counts, latencies, percentiles). -- Serve an HTML status dashboard and JSON/health HTTP endpoints. -- Report metrics to logs on a periodic interval. - -## 1. Health Checks - -### 1.1 Basic Health Check (HealthCheckService) - -`CheckHealthAsync()` evaluates: - -| Check | Healthy | Degraded | -|-------|---------|----------| -| MxAccess connected | Yes | — | -| Success rate (if > 100 total ops) | ≥ 50% | < 50% | -| Client count | ≤ 100 | > 100 | - -Returns health data dictionary: `scada_connected`, `scada_connection_state`, `total_clients`, `total_tags`, `total_operations`, `average_success_rate`. - -### 1.2 Detailed Health Check (DetailedHealthCheckService) - -`CheckHealthAsync()` performs an active probe: - -1. Checks `IsConnected` — returns **Unhealthy** if not connected. -2. Reads a test tag (default `System.Heartbeat`). -3. If test tag quality is not Good — returns **Degraded**. -4. If test tag timestamp is older than **5 minutes** — returns **Degraded** (stale data detection). -5. Otherwise returns **Healthy**. - -## 2. Performance Metrics - -### 2.1 Tracking - -`PerformanceMetrics` uses a `ConcurrentDictionary` to track operations by name. - -Operations tracked: `Read`, `ReadBatch`, `Write`, `WriteBatch` (recorded by ScadaGrpcService). - -### 2.2 Recording - -Two recording patterns: -- `RecordOperation(name, duration, success)` — explicit recording. -- `BeginOperation(name)` — returns an `ITimingScope` (disposable). On dispose, automatically records duration (via `Stopwatch`) and success flag (set via `SetSuccess(bool)`). - -### 2.3 Per-Operation Statistics - -`OperationMetrics` maintains: -- `_totalCount`, `_successCount` — running counters. -- `_totalMilliseconds`, `_minMilliseconds`, `_maxMilliseconds` — latency range. -- `_durations` — rolling buffer of up to **1000 latency samples** for percentile calculation. - -`MetricsStatistics` snapshot: -- `TotalCount`, `SuccessCount`, `SuccessRate` (percentage). -- `AverageMilliseconds`, `MinMilliseconds`, `MaxMilliseconds`. -- `Percentile95Milliseconds` — calculated from sorted samples at the 95th percentile index. - -### 2.4 Periodic Reporting - -A timer fires every **60 seconds**, logging a summary of all operation metrics to Serilog. - -## 3. Status Web Server - -### 3.1 Server - -`StatusWebServer` uses `HttpListener` on `http://+:{Port}/` (default port 8080). - -- Starts an async request-handling loop, spawning a task per request. -- Graceful shutdown: cancels the listener, waits **5 seconds** for the listener task to exit. -- Returns HTTP 405 for non-GET methods, HTTP 500 on errors. - -### 3.2 Endpoints - -| Endpoint | Method | Response | -|----------|--------|----------| -| `/` | GET | HTML dashboard (auto-refresh every 30 seconds) | -| `/api/status` | GET | JSON status report (camelCase) | -| `/api/health` | GET | Plain text `OK` (200) or `UNHEALTHY` (503) | - -### 3.3 HTML Dashboard - -Generated by `StatusReportService`: -- Bootstrap-like CSS grid layout with status cards. -- Color-coded status: green = Healthy, yellow = Degraded, red = Unhealthy/Error. -- Operations table with columns: Count, SuccessRate, Avg/Min/Max/P95 milliseconds. -- Service metadata: ServiceName, Version (assembly version), connection state. -- Subscription stats: TotalClients, TotalTags, ActiveSubscriptions. -- Auto-refresh via ``. -- Last updated timestamp. - -### 3.4 JSON Status Report - -Fully nested structure with camelCase property names: -- Service metadata, connection status, subscription stats, performance data, health check results. - -## Dependencies - -- **MxAccessClient** — `IsConnected`, `ConnectionState` for health checks; test tag read for detailed check. -- **SubscriptionManager** — subscription statistics. -- **PerformanceMetrics** — operation statistics for status report and health evaluation. -- **Configuration** — `WebServerConfiguration` for port and prefix. - -## Interactions - -- **GrpcServer** populates PerformanceMetrics via timing scopes on every RPC. -- **ServiceHost** creates all health/metrics/status components at startup and disposes them at shutdown. -- External monitoring systems can poll `/api/health` for availability checks. diff --git a/deprecated/lmxproxy/docs/requirements/Component-MxAccessClient.md b/deprecated/lmxproxy/docs/requirements/Component-MxAccessClient.md deleted file mode 100644 index a5ed4ff..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-MxAccessClient.md +++ /dev/null @@ -1,108 +0,0 @@ -# Component: MxAccessClient - -## Purpose - -The core component that wraps the ArchestrA MXAccess COM API, providing connection management, tag read/write operations, and subscription-based value change notifications. This is the bridge between the gRPC service layer and AVEVA System Platform. - -## Location - -`src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs` — partial class split across 6 files: -- `MxAccessClient.cs` — Main class, properties, disposal, factory. -- `MxAccessClient.Connection.cs` — Connection lifecycle (connect, disconnect, reconnect, cleanup). -- `MxAccessClient.ReadWrite.cs` — Read and write operations with retry and concurrency control. -- `MxAccessClient.Subscription.cs` — Subscription management and stored subscription state. -- `MxAccessClient.EventHandlers.cs` — COM event handlers (OnDataChange, OnWriteComplete, OperationComplete). -- `MxAccessClient.NestedTypes.cs` — Internal types and enums. - -## Responsibilities - -- Manage the MXAccess COM object lifecycle (create, register, unregister, release). -- Maintain connection state (Disconnected, Connecting, Connected, Disconnecting, Error, Reconnecting) and fire state change events. -- Execute read and write operations against MXAccess with concurrency control via semaphores. -- Manage tag subscriptions via MXAccess advise callbacks and store subscription state for reconnection. -- Handle COM threading constraints (STA thread context via `Task.Run`). - -## 1. Connection Lifecycle - -### 1.1 Connect - -`ConnectAsync()` wraps `ConnectInternal()` in `Task.Run` for STA thread context: - -1. Validates not disposed. -2. Returns early if already connected. -3. Sets state to `Connecting`. -4. `InitializeMxAccessConnection()` — creates new `LMXProxyServer` COM object, wires event handlers (OnDataChange, OnWriteComplete, OperationComplete). -5. `RegisterWithMxAccess()` — calls `_lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host")`, stores the returned connection handle. -6. Sets state to `Connected`. -7. On error, calls `Cleanup()` and re-throws. - -After successful connection, calls `RecreateStoredSubscriptionsAsync()` to restore any previously active subscriptions. - -### 1.2 Disconnect - -`DisconnectAsync()` wraps `DisconnectInternal()` in `Task.Run`: - -1. Checks `IsConnected`. -2. Sets state to `Disconnecting`. -3. `RemoveAllSubscriptions()` — unsubscribes all tags from MXAccess but retains subscription state in `_storedSubscriptions` for reconnection. -4. `UnregisterFromMxAccess()` — calls `_lmxProxy.Unregister(_connectionHandle)`. -5. `Cleanup()` — removes event handlers, calls `Marshal.ReleaseComObject(_lmxProxy)` to force-release all COM references, nulls the proxy and resets the connection handle. -6. Sets state to `Disconnected`. - -### 1.3 Connection State - -- `IsConnected` property: `_lmxProxy != null && _connectionState == Connected && _connectionHandle > 0`. -- `ConnectionState` enum: Disconnected, Connecting, Connected, Disconnecting, Error, Reconnecting. -- `ConnectionStateChanged` event fires on all state transitions with previous state, current state, and optional message. - -### 1.4 Auto-Reconnect - -When `AutoReconnect` is enabled (default), the `MonitorConnectionAsync` loop runs continuously: -- Checks `IsConnected` every `MonitorIntervalSeconds` (default 5 seconds). -- On disconnect, attempts reconnect via semaphore-protected `ConnectAsync()`. -- On failure, logs warning and retries at the next interval. -- Reconnection restores stored subscriptions automatically. - -## 2. Thread Safety & COM Constraints - -- State mutations protected by `lock (_lock)`. -- COM operations wrapped in `Task.Run` for STA thread context (MXAccess is 32-bit COM). -- Concurrency control: `_readSemaphore` and `_writeSemaphore` limit concurrent MXAccess operations to `MaxConcurrentOperations` (default 10, configurable). -- Default max concurrency constant: `DefaultMaxConcurrency = 10`. - -## 3. Read Operations - -- `ReadAsync(address, ct)` — Applies Polly retry policy, calls `ReadSingleValueAsync()`, returns `Vtq`. -- `ReadBatchAsync(addresses, ct)` — Creates parallel tasks per address via `ReadAddressWithSemaphoreAsync()`. Each task acquires `_readSemaphore` before reading. Returns `IReadOnlyDictionary`. - -## 4. Write Operations - -- `WriteAsync(address, value, ct)` — Applies Polly retry policy, calls `WriteInternalAsync(address, value, ct)`. -- `WriteBatchAsync(values, ct)` — Parallel tasks via `WriteAddressWithSemaphoreAsync()`. Each task acquires `_writeSemaphore` before writing. -- `WriteBatchAndWaitAsync(values, flagAddress, flagValue, responseAddress, responseValue, ct)` — Writes batch, writes flag, polls response tag until match. - -## 5. Subscription Management - -- Subscriptions stored in `_storedSubscriptions` for reconnection persistence. -- `SubscribeInternalAsync(addresses, callback, storeSubscription)` — registers tags with MXAccess and stores subscription state. -- `RecreateStoredSubscriptionsAsync()` — called after reconnect to re-subscribe all previously active tags without re-storing. -- `RemoveAllSubscriptions()` — unsubscribes from MXAccess but retains `_storedSubscriptions`. - -## 6. Event Handlers - -- **OnDataChange** — Fired by MXAccess when a subscribed tag value changes. Routes the update to the SubscriptionManager. -- **OnWriteComplete** — Fired when an async write operation completes. -- **OperationComplete** — General operation completion callback. - -## Dependencies - -- **ArchestrA.MXAccess** COM interop assembly (`lib/ArchestrA.MXAccess.dll`). -- **Polly** — retry policies for read/write operations. -- **Configuration** — `ConnectionConfiguration` for timeouts, concurrency limits, and auto-reconnect settings. - -## Interactions - -- **GrpcServer** (ScadaGrpcService) delegates all SCADA operations to MxAccessClient via the `IScadaClient` interface. -- **SubscriptionManager** receives value change callbacks originating from MxAccessClient's COM event handlers. -- **HealthAndMetrics** queries `IsConnected` and `ConnectionState` for health checks. -- **ServiceHost** manages the MxAccessClient lifecycle (create at startup, dispose at shutdown). diff --git a/deprecated/lmxproxy/docs/requirements/Component-Protocol.md b/deprecated/lmxproxy/docs/requirements/Component-Protocol.md deleted file mode 100644 index 7ffe69b..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-Protocol.md +++ /dev/null @@ -1,301 +0,0 @@ -# Component: Protocol - -## Purpose - -Defines the gRPC protocol specification for communication between the LmxProxy Client and Host, including the proto file definition, code-first contracts, message schemas, value type system, and quality codes. The authoritative specification is `docs/lmxproxy_updates.md`. - -## Location - -- `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` — proto file (Host, proto-generated). -- `src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs` — code-first contracts (Client, protobuf-net.Grpc). -- `docs/lmxproxy_updates.md` — authoritative protocol specification. -- `docs/lmxproxy_protocol.md` — legacy v1 protocol documentation (superseded). - -## Responsibilities - -- Define the gRPC service interface (`scada.ScadaService`) and all message types. -- Ensure wire compatibility between the Host's proto-generated code and the Client's code-first contracts. -- Specify the VTQ data model: `TypedValue` for values, `QualityCode` for quality. -- Document OPC UA-aligned quality codes filtered to AVEVA System Platform usage. - -## 1. Service Definition - -Service: `scada.ScadaService` (gRPC package: `scada`) - -| RPC | Request | Response | Type | -|-----|---------|----------|------| -| Connect | ConnectRequest | ConnectResponse | Unary | -| Disconnect | DisconnectRequest | DisconnectResponse | Unary | -| GetConnectionState | GetConnectionStateRequest | GetConnectionStateResponse | Unary | -| Read | ReadRequest | ReadResponse | Unary | -| ReadBatch | ReadBatchRequest | ReadBatchResponse | Unary | -| Write | WriteRequest | WriteResponse | Unary | -| WriteBatch | WriteBatchRequest | WriteBatchResponse | Unary | -| WriteBatchAndWait | WriteBatchAndWaitRequest | WriteBatchAndWaitResponse | Unary | -| Subscribe | SubscribeRequest | stream VtqMessage | Server streaming | -| CheckApiKey | CheckApiKeyRequest | CheckApiKeyResponse | Unary | - -## 2. Value Type System (TypedValue) - -Values are transmitted in their native protobuf types via a `TypedValue` oneof. No string serialization or parsing heuristics are used. - -``` -TypedValue { - oneof value { - bool bool_value = 1 - int32 int32_value = 2 - int64 int64_value = 3 - float float_value = 4 - double double_value = 5 - string string_value = 6 - bytes bytes_value = 7 - int64 datetime_value = 8 // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - ArrayValue array_value = 9 // typed arrays - } -} -``` - -`ArrayValue` contains typed repeated fields via oneof: `BoolArray`, `Int32Array`, `Int64Array`, `FloatArray`, `DoubleArray`, `StringArray`. Each contains a `repeated` field of the corresponding primitive. - -### 2.1 Null Handling - -- Null is represented by an unset `oneof` (no field selected in `TypedValue`). -- A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp. - -### 2.2 Type Mapping from Internal Tag Model - -| Tag Data Type | TypedValue Field | -|---------------|-----------------| -| `bool` | `bool_value` | -| `int32` | `int32_value` | -| `int64` | `int64_value` | -| `float` | `float_value` | -| `double` | `double_value` | -| `string` | `string_value` | -| `byte[]` | `bytes_value` | -| `DateTime` | `datetime_value` (UTC Ticks as int64) | -| `float[]` | `array_value.float_values` | -| `int32[]` | `array_value.int32_values` | -| Other arrays | Corresponding `ArrayValue` field | - -## 3. Quality System (QualityCode) - -Quality is a structured message with an OPC UA-compatible numeric status code and a human-readable symbolic name: - -``` -QualityCode { - uint32 status_code = 1 // OPC UA-compatible numeric status code - string symbolic_name = 2 // Human-readable name (e.g., "Good", "BadSensorFailure") -} -``` - -### 3.1 Category Extraction - -Category derived from high bits via `(statusCode & 0xC0000000)`: -- `0x00000000` = Good -- `0x40000000` = Uncertain -- `0x80000000` = Bad - -```csharp -public static bool IsGood(uint statusCode) => (statusCode & 0xC0000000) == 0x00000000; -public static bool IsBad(uint statusCode) => (statusCode & 0xC0000000) == 0x80000000; -``` - -### 3.2 Supported Quality Codes - -Filtered to codes actively used by AVEVA System Platform, InTouch, and OI Server/DAServer (per AVEVA Tech Note TN1305): - -**Good Quality:** - -| Symbolic Name | OPC UA Status Code | AVEVA OPC DA Hex | Description | -|--------------|-------------------|------------------|-------------| -| `Good` | `0x00000000` | `0x00C0` | Value is reliable, non-specific | -| `GoodLocalOverride` | `0x00D80000` | `0x00D8` | Manually overridden; input disconnected | - -**Uncertain Quality:** - -| Symbolic Name | OPC UA Status Code | AVEVA OPC DA Hex | Description | -|--------------|-------------------|------------------|-------------| -| `UncertainLastUsableValue` | `0x40900000` | `0x0044` | External source stopped writing; value is stale | -| `UncertainSensorNotAccurate` | `0x42390000` | `0x0050` | Sensor out of calibration or clamped | -| `UncertainEngineeringUnitsExceeded` | `0x40540000` | `0x0054` | Outside defined engineering limits | -| `UncertainSubNormal` | `0x40580000` | `0x0058` | Derived from insufficient good sources | - -**Bad Quality:** - -| Symbolic Name | OPC UA Status Code | AVEVA OPC DA Hex | Description | -|--------------|-------------------|------------------|-------------| -| `Bad` | `0x80000000` | `0x0000` | Non-specific bad; value not useful | -| `BadConfigurationError` | `0x80040000` | `0x0004` | Server config problem (e.g., item deleted) | -| `BadNotConnected` | `0x808A0000` | `0x0008` | Input not logically connected to source | -| `BadDeviceFailure` | `0x806B0000` | `0x000C` | Device failure detected | -| `BadSensorFailure` | `0x806D0000` | `0x0010` | Sensor failure detected | -| `BadLastKnownValue` | `0x80050000` | `0x0014` | Comm failed; last known value available | -| `BadCommunicationFailure` | `0x80050000` | `0x0018` | Comm failed; no last known value | -| `BadOutOfService` | `0x808F0000` | `0x001C` | Block off-scan/locked; item inactive | -| `BadWaitingForInitialData` | `0x80320000` | — | Initializing; OI Server establishing communication | - -**Notes:** -- AVEVA OPC DA quality codes use a 16-bit structure: 2 bits major (Good/Bad/Uncertain), 4 bits minor (sub-status), 2 bits limit (Not Limited, Low, High, Constant). The OPC UA status codes above are the standard UA equivalents. -- The limit bits are appended to any quality code. For example, `Good + High Limited` = `0x00C2` in OPC DA. In OPC UA, limits are conveyed via separate status code bits but the base code remains the same. - -### 3.3 Error Condition Mapping - -| Scenario | Quality | -|----------|---------| -| Normal read | `Good` (`0x00000000`) | -| Tag not found | `BadConfigurationError` (`0x80040000`) | -| Tag read exception / comms loss | `BadCommunicationFailure` (`0x80050000`) | -| Sensor failure | `BadSensorFailure` (`0x806D0000`) | -| Device failure | `BadDeviceFailure` (`0x806B0000`) | -| Stale value | `UncertainLastUsableValue` (`0x40900000`) | -| Block off-scan / disabled | `BadOutOfService` (`0x808F0000`) | -| Local override active | `GoodLocalOverride` (`0x00D80000`) | -| Initializing / waiting for first value | `BadWaitingForInitialData` (`0x80320000`) | -| Write to read-only tag | `WriteResult.success=false`, message indicates read-only | -| Type mismatch on write | `WriteResult.success=false`, message indicates type mismatch | - -## 4. Message Schemas - -### 4.1 VtqMessage - -The core data type for tag value transport: - -| Field | Proto Type | Order | Description | -|-------|-----------|-------|-------------| -| tag | string | 1 | Tag address | -| value | TypedValue | 2 | Typed value (native protobuf types) | -| timestamp_utc_ticks | int64 | 3 | UTC DateTime.Ticks (100ns intervals since 0001-01-01) | -| quality | QualityCode | 4 | Structured quality with status code and symbolic name | - -A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp. - -### 4.2 Connection Messages - -**ConnectRequest**: `client_id` (string), `api_key` (string) -**ConnectResponse**: `success` (bool), `message` (string), `session_id` (string — 32-char hex GUID) - -**DisconnectRequest**: `session_id` (string) -**DisconnectResponse**: `success` (bool), `message` (string) - -**GetConnectionStateRequest**: `session_id` (string) -**GetConnectionStateResponse**: `is_connected` (bool), `client_id` (string), `connected_since_utc_ticks` (int64) - -### 4.3 Read Messages - -**ReadRequest**: `session_id` (string), `tag` (string) -**ReadResponse**: `success` (bool), `message` (string), `vtq` (VtqMessage) - -**ReadBatchRequest**: `session_id` (string), `tags` (repeated string) -**ReadBatchResponse**: `success` (bool), `message` (string), `vtqs` (repeated VtqMessage) - -### 4.4 Write Messages - -**WriteRequest**: `session_id` (string), `tag` (string), `value` (TypedValue) -**WriteResponse**: `success` (bool), `message` (string) - -**WriteItem**: `tag` (string), `value` (TypedValue) -**WriteResult**: `tag` (string), `success` (bool), `message` (string) - -**WriteBatchRequest**: `session_id` (string), `items` (repeated WriteItem) -**WriteBatchResponse**: `success` (bool), `message` (string), `results` (repeated WriteResult) - -### 4.5 WriteBatchAndWait Messages - -**WriteBatchAndWaitRequest**: -- `session_id` (string) -- `items` (repeated WriteItem) — values to write -- `flag_tag` (string) — tag to poll after writes -- `flag_value` (TypedValue) — expected value (type-aware comparison) -- `timeout_ms` (int32) — max wait time (default 5000ms if ≤ 0) -- `poll_interval_ms` (int32) — polling interval (default 100ms if ≤ 0) - -**WriteBatchAndWaitResponse**: -- `success` (bool) -- `message` (string) -- `write_results` (repeated WriteResult) -- `flag_reached` (bool) — whether the flag value was matched -- `elapsed_ms` (int32) — total elapsed time - -**Behavior:** -1. All writes execute first. If any write fails, returns immediately with `success=false`. -2. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals. -3. Uses type-aware `TypedValueEquals()` comparison (see Section 4.5.1). -4. If flag matches before timeout: `success=true`, `flag_reached=true`. -5. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error). - -#### 4.5.1 Flag Comparison Rules - -Type-aware comparison via `TypedValueEquals()`: -- Both values must have the same `oneof` case (same type). Mismatched types are never equal. -- Numeric comparison uses the native type's equality (no floating-point string round-trip issues). -- String comparison is case-sensitive. -- Bool comparison is direct equality. -- Null (unset `oneof`) equals null. Null does not equal any set value. -- Array comparison: element-by-element equality, same length required. -- `datetime_value` compared as `int64` equality (tick-level precision). - -### 4.6 Subscription Messages - -**SubscribeRequest**: `session_id` (string), `tags` (repeated string), `sampling_ms` (int32) -Response: streamed `VtqMessage` items. - -### 4.7 API Key Messages - -**CheckApiKeyRequest**: `api_key` (string) -**CheckApiKeyResponse**: `is_valid` (bool), `message` (string) - -## 5. Dual gRPC Stack Compatibility - -The Host and Client use different gRPC implementations: - -| Aspect | Host | Client | -|--------|------|--------| -| Stack | Grpc.Core (C-core) | Grpc.Net.Client | -| Contract | Proto file (`scada.proto`) + Grpc.Tools codegen | Code-first (`[ServiceContract]`, `[DataContract]`) via protobuf-net.Grpc | -| Runtime | .NET Framework 4.8 | .NET 10 | - -Both target `scada.ScadaService` and produce identical wire format. Field ordering in `[DataMember(Order = N)]` matches proto field numbers. - -## 6. V1 Legacy Protocol - -The current codebase implements the v1 protocol. The following describes v1 behavior that will be replaced during migration to v2. - -### 6.1 V1 Value Encoding - -All values transmitted as strings: -- Write direction: server parses string values in order: bool → int → long → double → DateTime → raw string. -- Read direction: server serializes via `.ToString()` (bool → lowercase, DateTime → ISO-8601, arrays → JSON). -- Client parses: double → bool → null (empty string) → raw string. - -### 6.2 V1 Quality - -Three-state string quality (`"Good"`, `"Uncertain"`, `"Bad"`, case-insensitive). OPC UA numeric ranges: ≥192 = Good, 64–191 = Uncertain, <64 = Bad. - -### 6.3 V1 → V2 Field Changes - -| Message | Field | V1 Type | V2 Type | -|---------|-------|---------|---------| -| VtqMessage | value | string | TypedValue | -| VtqMessage | quality | string | QualityCode | -| WriteRequest | value | string | TypedValue | -| WriteItem | value | string | TypedValue | -| WriteBatchAndWaitRequest | flag_value | string | TypedValue | - -All RPC signatures remain unchanged. Only value and quality fields change type. - -### 6.4 Migration Strategy - -Clean break — no backward compatibility layer. All clients and servers updated simultaneously. This is appropriate because LmxProxy is an internal protocol with a small, controlled client count. Dual-format support adds complexity with no long-term benefit. - -## Dependencies - -- **Grpc.Core** + **Grpc.Tools** — proto compilation and server hosting (Host). -- **protobuf-net.Grpc** — code-first contracts (Client). -- **Grpc.Net.Client** — HTTP/2 transport (Client). - -## Interactions - -- **GrpcServer** implements the service defined by this protocol. -- **Client** consumes the service defined by this protocol. -- **MxAccessClient** is the backend that executes the operations requested via the protocol. diff --git a/deprecated/lmxproxy/docs/requirements/Component-Security.md b/deprecated/lmxproxy/docs/requirements/Component-Security.md deleted file mode 100644 index 7371791..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-Security.md +++ /dev/null @@ -1,119 +0,0 @@ -# Component: Security - -## Purpose - -Provides API key-based authentication and role-based authorization for the gRPC service, along with TLS certificate management for transport security. - -## Location - -- `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs` — API key storage and validation. -- `src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs` — gRPC server interceptor for authentication/authorization. -- `src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs` — Client-side TLS channel factory. - -## Responsibilities - -- Load and hot-reload API keys from a JSON configuration file. -- Validate API keys on every gRPC request via a server interceptor. -- Enforce role-based access control (ReadOnly vs ReadWrite). -- Manage TLS certificates for server and optional mutual TLS. - -## 1. API Key Service - -### 1.1 Key Storage - -- Keys are stored in a JSON file (default `apikeys.json`). -- File format: `{ "ApiKeys": [{ "Key": "...", "Description": "...", "Role": "ReadOnly|ReadWrite", "Enabled": true|false }] }`. -- If the file does not exist at startup, the service auto-generates a default file with two random keys: one ReadOnly and one ReadWrite. - -### 1.2 Hot Reload - -- A `FileSystemWatcher` monitors the API key file for changes. -- Rapid changes are debounced (1-second minimum between reloads). -- `ReloadConfigurationAsync` uses a `SemaphoreSlim` to serialize reload operations. -- New and modified keys take effect on the next request. Removed or disabled keys reject future requests immediately. -- Active sessions are not affected by key changes — sessions are tracked independently by SessionManager. - -### 1.3 Validation - -- `ValidateApiKey(apiKey)` — Returns the `ApiKey` object if the key exists and `Enabled` is true, otherwise null. -- `HasRole(apiKey, requiredRole)` — Returns true if the key has the required role. Role hierarchy: ReadWrite implies ReadOnly. - -## 2. API Key Interceptor - -### 2.1 Authentication Flow - -The `ApiKeyInterceptor` intercepts every unary and server-streaming RPC: - -1. Extracts the `x-api-key` header from gRPC request metadata. -2. Calls `ApiKeyService.ValidateApiKey()`. -3. If the key is invalid or missing, returns `StatusCode.Unauthenticated`. -4. For write-protected methods (`Write`, `WriteBatch`, `WriteBatchAndWait`), checks that the key has the `ReadWrite` role. Returns `StatusCode.PermissionDenied` if the key is `ReadOnly`. -5. Adds the validated `ApiKey` to `context.UserState["ApiKey"]` for downstream use. -6. Continues to the service method. - -### 2.2 Write-Protected Methods - -These RPCs require the `ReadWrite` role: -- `Write` -- `WriteBatch` -- `WriteBatchAndWait` - -All other RPCs (`Connect`, `Disconnect`, `GetConnectionState`, `Read`, `ReadBatch`, `Subscribe`, `CheckApiKey`) are allowed for `ReadOnly` keys. - -## 3. API Key Model - -| Field | Type | Description | -|-------|------|-------------| -| Key | string | The secret API key value | -| Description | string | Human-readable name for the key | -| Role | ApiKeyRole | `ReadOnly` or `ReadWrite` | -| Enabled | bool | Whether the key is active | - -`ApiKeyRole` enum: `ReadOnly` (read and subscribe only), `ReadWrite` (full access including writes). - -## 4. TLS Configuration - -### 4.1 Server-Side (Host) - -Configured via `TlsConfiguration` in `appsettings.json`: - -| Setting | Default | Description | -|---------|---------|-------------| -| Enabled | false | Enable TLS on the gRPC server | -| ServerCertificatePath | `certs/server.crt` | PEM server certificate | -| ServerKeyPath | `certs/server.key` | PEM server private key | -| ClientCaCertificatePath | `certs/ca.crt` | CA certificate for mTLS client validation | -| RequireClientCertificate | false | Require client certificates (mutual TLS) | -| CheckCertificateRevocation | false | Check certificate revocation lists | - -If TLS is enabled but certificates are missing, the service generates self-signed certificates at startup. - -### 4.2 Client-Side - -`ClientTlsConfiguration` in the client library: - -| Setting | Default | Description | -|---------|---------|-------------| -| UseTls | false | Enable TLS on the client connection | -| ClientCertificatePath | null | Client certificate for mTLS | -| ClientKeyPath | null | Client private key for mTLS | -| ServerCaCertificatePath | null | Custom CA for server validation | -| ServerNameOverride | null | SNI/hostname override | -| ValidateServerCertificate | true | Validate the server certificate chain | -| AllowSelfSignedCertificates | false | Accept self-signed server certificates | -| IgnoreAllCertificateErrors | false | Skip all certificate validation (dangerous) | - -- SSL protocols: TLS 1.2 and TLS 1.3. -- Client certificates loaded from PEM files and converted to PKCS12. -- Custom CA trust store support via chain building. - -## Dependencies - -- **Configuration** — TLS settings and API key file path from `appsettings.json`. -- **System.IO.FileSystemWatcher** — API key file change detection. - -## Interactions - -- **GrpcServer** — the ApiKeyInterceptor runs before every RPC in ScadaGrpcService. -- **ServiceHost** — creates ApiKeyService and ApiKeyInterceptor at startup, configures gRPC server credentials. -- **Client** — GrpcChannelFactory creates TLS-configured gRPC channels in LmxProxyClient. diff --git a/deprecated/lmxproxy/docs/requirements/Component-ServiceHost.md b/deprecated/lmxproxy/docs/requirements/Component-ServiceHost.md deleted file mode 100644 index 634b12c..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-ServiceHost.md +++ /dev/null @@ -1,108 +0,0 @@ -# Component: ServiceHost - -## Purpose - -The entry point and lifecycle manager for the LmxProxy Windows service. Handles Topshelf service hosting, Serilog logging setup, component initialization/teardown ordering, and Windows SCM service recovery configuration. - -## Location - -- `src/ZB.MOM.WW.LmxProxy.Host/Program.cs` — entry point, Serilog setup, Topshelf configuration. -- `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs` — service lifecycle (Start, Stop, Pause, Continue, Shutdown). - -## Responsibilities - -- Configure and launch the Topshelf Windows service. -- Load and validate configuration from `appsettings.json`. -- Initialize Serilog logging. -- Orchestrate service startup: create all components in dependency order, connect to MxAccess, start servers. -- Orchestrate service shutdown: stop servers, dispose all components in reverse order. -- Configure Windows SCM service recovery policies. - -## 1. Entry Point (Program.cs) - -1. Builds configuration from `appsettings.json` + environment variables via `ConfigurationBuilder`. -2. Configures Serilog from the `Serilog` section of appsettings (console + file sinks). -3. Validates configuration using `ConfigurationValidator.ValidateAndLog()`. -4. Configures Topshelf `HostFactory`: - - Service name: `ZB.MOM.WW.LmxProxy.Host` - - Display name: `SCADA Bridge LMX Proxy` - - Start automatically on boot. - - Service recovery: first failure 1 min, second 5 min, subsequent 10 min, reset period 1 day. -5. Runs the Topshelf host (blocks until service stops). - -## 2. Service Lifecycle (LmxProxyService) - -### 2.1 Startup Sequence (Start) - -Components are created and started in dependency order: - -1. Validate configuration. -2. Check/generate TLS certificates (if TLS enabled). -3. Create `PerformanceMetrics`. -4. Create `ApiKeyService` — loads API keys from file. -5. Create `MxAccessClient` via factory. -6. Subscribe to connection state changes. -7. Connect to MxAccess synchronously — times out at `ConnectionTimeoutSeconds` (default 30s). -8. Start `MonitorConnectionAsync` (if `AutoReconnect` enabled). -9. Create `SubscriptionManager`. -10. Create `SessionManager`. -11. Create `HealthCheckService` + `DetailedHealthCheckService`. -12. Create `StatusReportService` + `StatusWebServer`. -13. Create `ScadaGrpcService`. -14. Create `ApiKeyInterceptor`. -15. Configure gRPC `Server` with TLS or insecure credentials. -16. Start gRPC server on `0.0.0.0:{GrpcPort}`. -17. Start `StatusWebServer`. - -### 2.2 Shutdown Sequence (Stop) - -Components are stopped and disposed in reverse order: - -1. Cancel reconnect monitor — wait **5 seconds** for exit. -2. Graceful gRPC server shutdown — **10-second** timeout, then kill. -3. Stop StatusWebServer — **5-second** wait. -4. Dispose all components in reverse creation order. -5. Disconnect from MxAccess — **10-second** timeout. - -### 2.3 Other Lifecycle Events - -- **Pause**: Supported by Topshelf but behavior is a no-op beyond logging. -- **Continue**: Resume from pause, no-op beyond logging. -- **Shutdown**: System shutdown signal, triggers the same shutdown sequence as Stop. - -## 3. Service Recovery (Windows SCM) - -Configured via Topshelf's `EnableServiceRecovery`: - -| Failure | Action | Delay | -|---------|--------|-------| -| First | Restart service | 1 minute | -| Second | Restart service | 5 minutes | -| Subsequent | Restart service | 10 minutes | -| Reset period | — | 1 day | - -All values are configurable via `ServiceRecoveryConfiguration`. - -## 4. Service Identity - -| Property | Value | -|----------|-------| -| Service name | `ZB.MOM.WW.LmxProxy.Host` | -| Display name | `SCADA Bridge LMX Proxy` | -| Start mode | Automatic | -| Platform | x86 (.NET Framework 4.8) | -| Framework | Topshelf | - -## Dependencies - -- **Topshelf** — Windows service framework. -- **Serilog** — structured logging (console + file sinks). -- **Microsoft.Extensions.Configuration** — configuration loading. -- **Configuration** — validated configuration objects. -- All other components are created and managed by LmxProxyService. - -## Interactions - -- **Configuration** is loaded and validated first; all other components receive their settings from it. -- **MxAccessClient** is connected synchronously during startup. If connection fails within the timeout, the service fails to start. -- **GrpcServer** and **StatusWebServer** are started last, after all dependencies are ready. diff --git a/deprecated/lmxproxy/docs/requirements/Component-SessionManager.md b/deprecated/lmxproxy/docs/requirements/Component-SessionManager.md deleted file mode 100644 index c984926..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-SessionManager.md +++ /dev/null @@ -1,76 +0,0 @@ -# Component: SessionManager - -## Purpose - -Tracks active client sessions, mapping session IDs to client metadata. Provides session creation, validation, and termination for the gRPC service layer. - -## Location - -`src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs` - -## Responsibilities - -- Create new sessions with unique identifiers when clients connect. -- Validate session IDs on every data operation. -- Track session metadata (client ID, API key, connection time, last activity). -- Terminate sessions on client disconnect. -- Provide session listing for monitoring and status reporting. - -## 1. Session Storage - -- Sessions are stored in a `ConcurrentDictionary` (lock-free, thread-safe). -- Session state is in-memory only — all sessions are lost on service restart. -- `ActiveSessionCount` property returns the current count of tracked sessions. - -## 2. Session Lifecycle - -### 2.1 Creation - -`CreateSession(clientId, apiKey)`: -- Generates a unique session ID: `Guid.NewGuid().ToString("N")` (32-character lowercase hex string, no hyphens). -- Creates a `SessionInfo` record with `ConnectedAt` and `LastActivity` set to `DateTime.UtcNow`. -- Stores the session in the dictionary. -- Returns the session ID to the client. - -### 2.2 Validation - -`ValidateSession(sessionId)`: -- Looks up the session ID in the dictionary. -- If found, updates `LastActivity` to `DateTime.UtcNow` and returns `true`. -- If not found, returns `false`. - -### 2.3 Termination - -`TerminateSession(sessionId)`: -- Removes the session from the dictionary. -- Returns `true` if the session existed, `false` otherwise. - -### 2.4 Query - -- `GetSession(sessionId)` — Returns `SessionInfo` or `null` if not found. -- `GetAllSessions()` — Returns `IReadOnlyList` snapshot of all active sessions. - -## 3. SessionInfo - -| Field | Type | Description | -|-------|------|-------------| -| SessionId | string | 32-character hex GUID | -| ClientId | string | Client-provided identifier | -| ApiKey | string | API key used for authentication | -| ConnectedAt | DateTime | UTC time of session creation | -| LastActivity | DateTime | UTC time of last operation (updated on each validation) | -| ConnectedSinceUtcTicks | long | `ConnectedAt.Ticks` for gRPC response serialization | - -## 4. Disposal - -`Dispose()` clears all sessions from the dictionary. No notifications are sent to connected clients. - -## Dependencies - -None. SessionManager is a standalone in-memory store with no external dependencies. - -## Interactions - -- **GrpcServer** calls `CreateSession` on Connect, `ValidateSession` on every data operation, and `TerminateSession` on Disconnect. -- **HealthAndMetrics** reads `ActiveSessionCount` for health check data. -- **StatusReportService** reads session information for the status dashboard. diff --git a/deprecated/lmxproxy/docs/requirements/Component-SubscriptionManager.md b/deprecated/lmxproxy/docs/requirements/Component-SubscriptionManager.md deleted file mode 100644 index e06fe28..0000000 --- a/deprecated/lmxproxy/docs/requirements/Component-SubscriptionManager.md +++ /dev/null @@ -1,116 +0,0 @@ -# Component: SubscriptionManager - -## Purpose - -Manages the lifecycle of tag value subscriptions, multiplexing multiple client subscriptions onto shared MXAccess tag subscriptions and delivering updates via per-client bounded channels with configurable backpressure. - -## Location - -`src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs` - -## Responsibilities - -- Create per-client subscription channels with bounded capacity. -- Share underlying MXAccess tag subscriptions across multiple clients subscribing to the same tags. -- Deliver tag value updates from MXAccess callbacks to all subscribed clients. -- Handle backpressure when client channels are full (DropOldest, DropNewest, or Wait). -- Clean up subscriptions on client disconnect. -- Notify all subscribed clients with bad quality when MXAccess disconnects. - -## 1. Architecture - -### 1.1 Per-Client Channels - -Each subscribing client gets a bounded `System.Threading.Channel<(string address, Vtq vtq)>`: -- Capacity: configurable (default 1000 messages). -- Full mode: configurable (default `DropOldest`). -- `SingleReader = true`, `SingleWriter = false`. - -### 1.2 Shared Tag Subscriptions - -Tag subscriptions to MXAccess are shared across clients: -- When the first client subscribes to a tag, a new MXAccess subscription is created. -- When additional clients subscribe to the same tag, they are added to the existing tag subscription's client set. -- When the last client unsubscribes from a tag, the MXAccess subscription is disposed. - -### 1.3 Thread Safety - -- `ReaderWriterLockSlim` protects tag subscription updates. -- `ConcurrentDictionary` for client subscription tracking. - -## 2. Subscription Flow - -### 2.1 Subscribe - -`SubscribeAsync(clientId, addresses, ct)`: - -1. Creates a bounded channel with configured capacity and full mode. -2. Creates a `ClientSubscription` record (clientId, channel, address set, CancellationTokenSource, counters). -3. For each tag address: - - If the tag already has a subscription, adds the client to the existing `TagSubscription.clientIds` set. - - Otherwise, creates a new `TagSubscription` and calls `_scadaClient.SubscribeAsync()` to register with MXAccess (outside the lock to avoid blocking). -4. Registers a cancellation token callback to automatically call `UnsubscribeClient` on disconnect. -5. Returns the channel reader for the GrpcServer to stream from. - -### 2.2 Value Updates - -`OnTagValueChanged(address, Vtq)` — called from MxAccessClient's COM event handler: - -1. Looks up the tag subscription to find all subscribed clients. -2. For each client, calls `channel.Writer.TryWrite((address, vtq))`. -3. If the channel is full: - - **DropOldest**: Logs a warning, increments `DroppedMessageCount`. The oldest message is automatically discarded by the channel. - - **DropNewest**: Drops the incoming message. - - **Wait**: Blocks the writer until space is available (not recommended for gRPC streaming). -4. On channel closed (client disconnected), schedules `UnsubscribeClient` cleanup. - -### 2.3 Unsubscribe - -`UnsubscribeClient(clientId)`: - -1. Removes the client from the client dictionary. -2. For each tag the client was subscribed to, removes the client from the tag's subscriber set. -3. If a tag has no remaining subscribers, disposes the MXAccess subscription handle. -4. Completes the client's channel writer (signals end of stream). - -## 3. Backpressure - -| Mode | Behavior | Use Case | -|------|----------|----------| -| DropOldest | Silently discards oldest message when channel is full | Default. Fire-and-forget semantics. No client blocking. | -| DropNewest | Drops the incoming message when channel is full | Preserves history, drops latest updates. | -| Wait | Blocks the writer until space is available | Not recommended for gRPC streaming (blocks callback thread). | - -Per-client statistics track `DeliveredMessageCount` and `DroppedMessageCount` for monitoring via the status dashboard. - -## 4. Disconnection Handling - -### 4.1 Client Disconnect - -When a client's gRPC stream ends (cancellation or error), the cancellation token callback triggers `UnsubscribeClient`, which cleans up all tag subscriptions for that client. - -### 4.2 MxAccess Disconnect - -`OnConnectionStateChanged` — when the MxAccess connection drops: -- Sends a bad-quality Vtq to all subscribed clients via their channels. -- Each client receives an async notification of the connection loss. -- Tag subscriptions are retained in memory for reconnection (via MxAccessClient's `_storedSubscriptions`). - -## 5. Statistics - -`GetSubscriptionStats()` returns: -- `TotalClients` — number of active client subscriptions. -- `TotalTags` — number of unique tags with active MXAccess subscriptions. -- `ActiveSubscriptions` — total client-tag subscription count. - -## Dependencies - -- **MxAccessClient** (IScadaClient) — creates and disposes MXAccess tag subscriptions. -- **Configuration** — `SubscriptionConfiguration` for channel capacity and full mode. - -## Interactions - -- **GrpcServer** calls `SubscribeAsync` on Subscribe RPC and reads from the returned channel. -- **MxAccessClient** delivers value updates via the `OnTagValueChanged` callback. -- **HealthAndMetrics** reads subscription statistics for health checks and status reports. -- **ServiceHost** disposes the SubscriptionManager at shutdown. diff --git a/deprecated/lmxproxy/docs/requirements/HighLevelReqs.md b/deprecated/lmxproxy/docs/requirements/HighLevelReqs.md deleted file mode 100644 index 71d1cbd..0000000 --- a/deprecated/lmxproxy/docs/requirements/HighLevelReqs.md +++ /dev/null @@ -1,274 +0,0 @@ -# LmxProxy - High Level Requirements - -## 1. System Purpose - -LmxProxy is a gRPC proxy service that bridges SCADA clients to AVEVA System Platform (Wonderware) via the ArchestrA MXAccess COM API. It exists because MXAccess is a 32-bit COM component that requires co-location with System Platform on a Windows machine running .NET Framework 4.8. LmxProxy isolates this constraint behind a gRPC interface, allowing modern .NET clients to access System Platform data remotely over HTTP/2. - -## 2. Architecture - -### 2.1 Two-Project Structure - -- **ZB.MOM.WW.LmxProxy.Host** — .NET Framework 4.8, x86-only Windows service. Hosts a gRPC server (Grpc.Core) fronting the MXAccess COM API. Runs on the same machine as AVEVA System Platform. -- **ZB.MOM.WW.LmxProxy.Client** — .NET 10, AnyCPU class library. Code-first gRPC client (protobuf-net.Grpc) consumed by ScadaLink's Data Connection Layer. Packaged as a NuGet library. - -### 2.2 Dual gRPC Stacks - -The two projects use different gRPC implementations that are wire-compatible: - -- **Host**: Proto-file-generated code via `Grpc.Core` + `Grpc.Tools`. Uses the deprecated C-core gRPC library because .NET Framework 4.8 does not support `Grpc.Net.Server`. -- **Client**: Code-first contracts via `protobuf-net.Grpc` with `[DataContract]`/`[ServiceContract]` attributes over `Grpc.Net.Client`. - -Both target the same `scada.ScadaService` gRPC service definition and are wire-compatible. - -### 2.3 Deployment Model - -- The Host service runs on the AVEVA System Platform machine (or any machine with MXAccess access). -- Clients connect remotely over gRPC (HTTP/2) on a configurable port (default 50051). -- The Host runs as a Windows service managed by Topshelf. - -## 3. Communication Protocol - -### 3.1 Transport - -- gRPC over HTTP/2. -- Default server port: 50051. -- Optional TLS with mutual TLS (mTLS) support. - -### 3.2 RPCs - -The service exposes 10 RPCs: - -| RPC | Type | Description | -|-----|------|-------------| -| Connect | Unary | Establish session, returns session ID | -| Disconnect | Unary | Terminate session | -| GetConnectionState | Unary | Query MxAccess connection status | -| Read | Unary | Read single tag value | -| ReadBatch | Unary | Read multiple tag values | -| Write | Unary | Write single tag value | -| WriteBatch | Unary | Write multiple tag values | -| WriteBatchAndWait | Unary | Write values, poll flag tag until match or timeout | -| Subscribe | Server streaming | Stream tag value updates to client | -| CheckApiKey | Unary | Validate API key and return role | - -### 3.3 Data Model (VTQ) - -All tag values are represented as VTQ (Value, Timestamp, Quality) tuples: - -- **Value**: `TypedValue` — a protobuf `oneof` carrying the value in its native type (bool, int32, int64, float, double, string, bytes, datetime, typed arrays). An unset `oneof` represents null. -- **Timestamp**: UTC `DateTime.Ticks` as `int64` (100-nanosecond intervals since 0001-01-01 00:00:00 UTC). -- **Quality**: `QualityCode` — a structured message with `uint32 status_code` (OPC UA-compatible) and `string symbolic_name`. Category derived from high bits: `0x00xxxxxx` = Good, `0x40xxxxxx` = Uncertain, `0x80xxxxxx` = Bad. - -## 4. Session Lifecycle - -- Clients call `Connect` with a client ID and optional API key to establish a session. -- The server returns a 32-character hex GUID as the session ID. -- All subsequent operations require the session ID for validation. -- Sessions persist until explicit `Disconnect` or server restart. There is no idle timeout. -- Session state is tracked in memory (not persisted). All sessions are lost on service restart. - -## 5. Authentication & Authorization - -### 5.1 API Key Authentication - -- API keys are validated via the `x-api-key` gRPC metadata header. -- Keys are stored in a JSON file (`apikeys.json` by default) with hot-reload via FileSystemWatcher (1-second debounce). -- If no API key file exists, the service auto-generates a default file with two random keys (one ReadOnly, one ReadWrite). -- Authentication is enforced at the gRPC interceptor level before any service method executes. - -### 5.2 Role-Based Authorization - -Two roles with hierarchical permissions: - -| Role | Read | Subscribe | Write | -|------|------|-----------|-------| -| ReadOnly | Yes | Yes | No | -| ReadWrite | Yes | Yes | Yes | - -Write-protected methods: `Write`, `WriteBatch`, `WriteBatchAndWait`. A ReadOnly key attempting a write receives `StatusCode.PermissionDenied`. - -### 5.3 TLS/Security - -- TLS is optional (disabled by default in configuration, though `Tls.Enabled` defaults to `true` in the config class). -- Supports server TLS and mutual TLS (client certificate validation). -- Client CA certificate path configurable for mTLS. -- Certificate revocation checking is optional. -- Client library supports TLS 1.2 and TLS 1.3, custom CA trust stores, self-signed certificate allowance, and server name override. - -## 6. Operations - -### 6.1 Read - -- Single tag read with configurable retry policy. -- Batch read with semaphore-controlled concurrency (default max 10 concurrent operations). -- Read timeout: 5 seconds (configurable). - -### 6.2 Write - -- Single tag write with retry policy. Values are sent as `TypedValue` (native protobuf types). Type mismatches between the value and the tag's expected type return a write failure. -- Batch write with semaphore-controlled concurrency. -- Write timeout: 5 seconds (configurable). -- WriteBatchAndWait: writes a batch, then polls the flag tag at a configurable interval until its value matches the expected flag value (type-aware comparison via `TypedValueEquals`) or a timeout expires. Default timeout: 5000ms, default poll interval: 100ms. Timeout is not an error — returns `flag_reached=false`. - -### 6.3 Subscribe - -- Server-streaming RPC. Client sends a list of tags and a sampling interval (in milliseconds). -- Server maintains a per-client bounded channel (default capacity 1000 messages). -- Updates are pushed as `VtqMessage` items on the stream. -- When the MxAccess connection drops, all subscribed clients receive a bad-quality notification. -- Subscriptions are cleaned up on client disconnect. When the last client unsubscribes from a tag, the underlying MxAccess subscription is disposed. - -## 7. Connection Resilience - -### 7.1 Host Auto-Reconnect - -- If the MxAccess connection is lost, the Host automatically attempts reconnection at a fixed interval (default 5 seconds). -- Stored subscriptions are recreated after a successful reconnect. -- Auto-reconnect is configurable (`Connection.AutoReconnect`, default true). - -### 7.2 Client Keep-Alive - -- The client sends a lightweight `GetConnectionState` ping every 30 seconds. -- On keep-alive failure, the client marks the connection as disconnected and cleans up subscriptions. - -### 7.3 Client Retry Policy - -- Polly-based exponential backoff retry. -- Default: 3 attempts with 1-second initial delay (1s → 2s → 4s). -- Transient errors retried: Unavailable, DeadlineExceeded, ResourceExhausted, Aborted. - -## 8. Health Monitoring & Metrics - -### 8.1 Health Checks - -Two health check implementations: - -- **Basic** (`HealthCheckService`): Checks MxAccess connection state, subscription stats, and operation success rate. Returns Degraded if success rate < 50% (with > 100 operations) or client count > 100. -- **Detailed** (`DetailedHealthCheckService`): Reads a test tag (`System.Heartbeat`). Returns Unhealthy if not connected, Degraded if test tag quality is not Good or timestamp is older than 5 minutes. - -### 8.2 Performance Metrics - -- Per-operation tracking: Read, ReadBatch, Write, WriteBatch. -- Metrics: total count, success count, success rate, average/min/max latency, 95th percentile latency. -- Rolling buffer of 1000 latency samples per operation for percentile calculation. -- Metrics reported to logs every 60 seconds. - -### 8.3 Status Web Server - -- HTTP status server on port 8080 (configurable). -- Endpoints: - - `GET /` — HTML dashboard with auto-refresh (30 seconds), color-coded status cards, operations table. - - `GET /api/status` — JSON status report. - - `GET /api/health` — Plain text `OK` (200) or `UNHEALTHY` (503). - -### 8.4 Client Metrics - -- Per-operation counts, error counts, and latency tracking (average, p95, p99). -- Rolling buffer of 1000 latency samples. -- Exposed via `ILmxProxyClient.GetMetrics()`. - -## 9. Service Hosting - -### 9.1 Topshelf Windows Service - -- Service name: `ZB.MOM.WW.LmxProxy.Host` -- Display name: `SCADA Bridge LMX Proxy` -- Starts automatically on boot. - -### 9.2 Service Recovery (Windows SCM) - -| Failure | Restart Delay | -|---------|--------------| -| First | 1 minute | -| Second | 5 minutes | -| Subsequent | 10 minutes | -| Reset period | 1 day | - -### 9.3 Startup Sequence - -1. Load configuration from `appsettings.json` + environment variables. -2. Configure Serilog (console + file sinks). -3. Validate configuration. -4. Check/generate TLS certificates (if TLS enabled). -5. Initialize services: PerformanceMetrics, ApiKeyService, MxAccessClient, SubscriptionManager, SessionManager, HealthCheckService, StatusReportService. -6. Connect to MxAccess synchronously (timeout: 30 seconds). -7. Start auto-reconnect monitor loop (if enabled). -8. Start gRPC server on configured port. -9. Start HTTP status web server. - -### 9.4 Shutdown Sequence - -1. Cancel reconnect monitor (5-second wait). -2. Graceful gRPC server shutdown (10-second timeout, then kill). -3. Stop status web server (5-second wait). -4. Dispose all components in reverse order. -5. Disconnect from MxAccess (10-second timeout). - -## 10. Configuration - -All configuration is via `appsettings.json` bound to `LmxProxyConfiguration`. Key settings: - -| Section | Setting | Default | -|---------|---------|---------| -| Root | GrpcPort | 50051 | -| Root | ApiKeyConfigFile | `apikeys.json` | -| Connection | MonitorIntervalSeconds | 5 | -| Connection | ConnectionTimeoutSeconds | 30 | -| Connection | ReadTimeoutSeconds | 5 | -| Connection | WriteTimeoutSeconds | 5 | -| Connection | MaxConcurrentOperations | 10 | -| Connection | AutoReconnect | true | -| Subscription | ChannelCapacity | 1000 | -| Subscription | ChannelFullMode | DropOldest | -| Tls | Enabled | false | -| Tls | RequireClientCertificate | false | -| WebServer | Enabled | true | -| WebServer | Port | 8080 | - -Configuration is validated at startup. Invalid values cause the service to fail to start. - -## 11. Logging - -- Serilog with console and file sinks. -- File sink: `logs/lmxproxy-.txt`, daily rolling, 30 files retained. -- Default level: Information. Overrides: Microsoft=Warning, System=Warning, Grpc=Information. -- Enrichment: FromLogContext, WithMachineName, WithThreadId. - -## 12. Constraints - -- Host **must** target x86 and .NET Framework 4.8 (MXAccess is 32-bit COM). -- Host uses `Grpc.Core` (deprecated C-core library), required because .NET 4.8 does not support `Grpc.Net.Server`. -- Client targets .NET 10 and runs in ScadaLink central/site clusters. -- MxAccess COM operations require STA thread context (wrapped in `Task.Run`). -- The solution file uses `.slnx` format. - -## 13. Protocol - -The protocol specification is defined in `lmxproxy_updates.md`, which is the authoritative source of truth. All RPC signatures, message schemas, and behavioral specifications are per that document. - -### 13.1 Value System (TypedValue) - -Values are transmitted in their native protobuf types via a `TypedValue` oneof: bool, int32, int64, float, double, string, bytes, datetime (int64 UTC Ticks), and typed arrays. An unset oneof represents null. No string serialization or parsing heuristics are used. - -### 13.2 Quality System (QualityCode) - -Quality is a structured `QualityCode` message with `uint32 status_code` (OPC UA-compatible) and `string symbolic_name`. Supports AVEVA-aligned quality sub-codes (e.g., `BadSensorFailure` = `0x806D0000`, `GoodLocalOverride` = `0x00D80000`, `BadWaitingForInitialData` = `0x80320000`). See Component-Protocol for the full quality code table. - -### 13.3 Migration from V1 - -The current codebase implements the v1 protocol (string-encoded values, three-state string quality). The v2 protocol is a clean break — all clients and servers will be updated simultaneously. No backward compatibility layer. This is appropriate because LmxProxy is an internal protocol with a small, controlled client count. - -## 14. Component List (10 Components) - -| # | Component | Description | -|---|-----------|-------------| -| 1 | GrpcServer | gRPC service implementation, session validation, request routing | -| 2 | MxAccessClient | MXAccess COM interop wrapper, connection lifecycle, read/write/subscribe | -| 3 | SessionManager | Client session tracking and lifecycle | -| 4 | Security | API key authentication, role-based authorization, TLS management | -| 5 | SubscriptionManager | Tag subscription lifecycle, channel-based update delivery, backpressure | -| 6 | Configuration | appsettings.json structure, validation, options binding | -| 7 | HealthAndMetrics | Health checks, performance metrics, status web server | -| 8 | ServiceHost | Topshelf hosting, startup/shutdown, logging setup, service recovery | -| 9 | Client | LmxProxyClient library, builder, retry, streaming, DI integration | -| 10 | Protocol | gRPC protocol specification, proto definition, code-first contracts | diff --git a/deprecated/lmxproxy/docs/sta_gap.md b/deprecated/lmxproxy/docs/sta_gap.md deleted file mode 100644 index da03845..0000000 --- a/deprecated/lmxproxy/docs/sta_gap.md +++ /dev/null @@ -1,167 +0,0 @@ -# STA Message Pump Gap — OnWriteComplete COM Callback - -**Status**: Documented gap. Fire-and-forget workaround in place (deviation #7). Full fix deferred until secured/verified writes are needed. - -## When This Matters - -The current fire-and-forget write approach works for **supervisory writes** where: -- Security is handled at the LmxProxy API key level, not MxAccess attribute level -- Writes succeed synchronously (no secured/verified write requirements) -- Write confirmation is handled at the application level (read-back in `WriteBatchAndWait`) - -This gap becomes a **blocking issue** if any of these scenarios arise: -- **Secured writes (MxAccess error 1012)**: Attribute requires ArchestrA user authentication. `OnWriteComplete` returns the error, and the caller must retry with `WriteSecured()`. -- **Verified writes (MxAccess error 1013)**: Attribute requires two-user verification. Same retry pattern. -- **Write failure detection**: MxAccess accepts the `Write()` call but can't complete it (e.g., downstream device failure). `OnWriteComplete` is the only notification of this — without it, the caller assumes success. - -## Root Cause - -The MxAccess documentation (Write() Method) states: *"Upon completion of the write, your program receives notification of the success/failure status through the OnWriteComplete() event"* and *"that item should not be taken off advise or removed from the internal tables until the OnWriteComplete() event is received."* - -`OnWriteComplete` **should** fire after every `Write()` call. It doesn't in our service because: -- MxAccess is a COM component designed for Windows Forms apps with a UI message loop -- COM event callbacks are delivered via the Windows message pump -- Our Topshelf Windows service has no message pump — `Write()` is called from thread pool threads (`Task.Run`) with no message loop -- `OnDataChange` works because MxAccess fires it proactively on its own internal threads; `OnWriteComplete` is a response callback that needs message-pump-based marshaling - -## Correct Solution: Dedicated STA Thread + `Application.Run()` - -Based on research (Stephen Toub, MSDN Magazine 2007; Microsoft Learn COM interop docs; community patterns), the correct approach is a dedicated STA thread running a Windows Forms message pump via `Application.Run()`. - -### Architecture - -``` -Service main thread (MTA) - │ - ├── gRPC server threads (handle client RPCs) - │ │ - │ └── Marshal COM calls via Form.BeginInvoke() ──┐ - │ │ - └── Dedicated STA thread │ - │ │ - ├── Creates LMXProxyServerClass COM object │ - ├── Wires event handlers (OnDataChange, │ - │ OnWriteComplete, OperationComplete) │ - ├── Runs Application.Run() ← continuous │ - │ message pump │ - │ │ - └── Hidden Form receives BeginInvoke calls ◄────┘ - │ - ├── Executes COM operations (Read, Write, - │ AddItem, AdviseSupervisory, etc.) - │ - └── COM callbacks delivered via message pump - (OnWriteComplete, OnDataChange, etc.) -``` - -### Implementation Pattern - -```csharp -// In MxAccessClient constructor or Start(): -var initDone = new ManualResetEventSlim(false); - -_staThread = new Thread(() => -{ - // 1. Create hidden form for marshaling - _marshalForm = new Form(); - _marshalForm.CreateHandle(); // force HWND creation without showing - - // 2. Create COM objects ON THIS THREAD - _lmxProxy = new LMXProxyServerClass(); - _lmxProxy.OnDataChange += OnDataChange; - _lmxProxy.OnWriteComplete += OnWriteComplete; - - // 3. Signal that init is complete - initDone.Set(); - - // 4. Run message pump (blocks forever, pumps COM callbacks) - Application.Run(); -}); -_staThread.Name = "MxAccess-STA"; -_staThread.IsBackground = true; -_staThread.SetApartmentState(ApartmentState.STA); -_staThread.Start(); - -initDone.Wait(); // wait for COM objects to be ready -``` - -### Dispatching Work to the STA Thread - -```csharp -// All COM calls must go through the hidden form's invoke: -public Task ReadAsync(string address, CancellationToken ct) -{ - var tcs = new TaskCompletionSource(); - _marshalForm.BeginInvoke((Action)(() => - { - try - { - // COM call executes on STA thread - int handle = _lmxProxy.AddItem(_connectionHandle, address); - _lmxProxy.AdviseSupervisory(_connectionHandle, handle); - // ... etc - tcs.SetResult(vtq); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - })); - return tcs.Task; -} -``` - -### Shutdown - -```csharp -// To stop the message pump: -_marshalForm.BeginInvoke((Action)(() => -{ - // Clean up COM objects on STA thread - // ... UnAdvise, RemoveItem, Unregister ... - Marshal.ReleaseComObject(_lmxProxy); - Application.ExitThread(); // stops Application.Run() -})); -_staThread.Join(TimeSpan.FromSeconds(10)); -``` - -### Why Our First Attempt Failed - -Our original `StaDispatchThread` (Phase 2) used `BlockingCollection.Take()` to wait for work items, with `Application.DoEvents()` between items. This failed because: - -| Our failed approach | Correct approach | -|---|---| -| `BlockingCollection.Take()` blocks the STA thread, preventing the message pump from running | `Application.Run()` runs continuously, pumping messages at all times | -| `Application.DoEvents()` only pumps messages already in the queue at that instant | Message pump runs an infinite loop, processing messages as they arrive | -| Work dispatched by enqueueing to `BlockingCollection` | Work dispatched via `Form.BeginInvoke()` which posts a Windows message to the STA thread's queue | - -The key difference: `BeginInvoke` posts a `WM_` message that the message pump processes alongside COM callbacks. `BlockingCollection` bypasses the message pump entirely. - -## Drawbacks of the STA Approach - -### Performance -- **All COM calls serialize onto one thread.** Under load (batch reads of 100+ tags), operations queue up single-file. Current `Task.Run` approach allows MxAccess's internal marshaling to handle some concurrency. -- **Double context switch per operation.** Caller → STA thread (invoke) → wait → back to caller. Adds ~0.1-1ms per call. Negligible for single reads, noticeable for large batch operations. - -### Safety -- **Single point of failure.** If the STA thread dies, all MxAccess operations stop. Recovery requires tearing down and recreating the thread + all COM objects. -- **Deadlock risk.** If STA thread code synchronously waits on something that needs the STA thread (circular dependency), the message pump freezes. All waits must be async/non-blocking. -- **Reentrancy.** While pumping messages, inbound COM callbacks can reenter your code during another COM call. Event handlers must be reentrant-safe. - -### Complexity -- Every COM call needs `_marshalForm.BeginInvoke()` wrapping. -- COM object affinity to STA thread is hard to enforce at compile time. -- Unit tests need STA thread support or must use fakes. - -## Decision - -Fire-and-forget is the correct choice for now. Revisit when secured/verified writes are needed. - -## References - -- [.NET Matters: Handling Messages in Console Apps — Stephen Toub, MSDN Magazine 2007](https://learn.microsoft.com/en-us/archive/msdn-magazine/2007/june/net-matters-handling-messages-in-console-apps) -- [How to: Support COM Interop by Displaying Each Windows Form on Its Own Thread — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/advanced/how-to-support-com-interop-by-displaying-each-windows-form-on-its-own-thread) -- [.NET Windows Service needs STAThread — hirenppatel](https://hirenppatel.wordpress.com/2012/11/24/net-windows-service-needs-to-use-stathread-instead-of-mtathread/) -- [Application.Run() In a Windows Service — PC Review](https://www.pcreview.co.uk/threads/application-run-in-a-windows-service.3087159/) -- [Build a message pump for a Windows service? — CodeProject](https://www.codeproject.com/Messages/1365966/Build-a-message-pump-for-a-Windows-service.aspx) -- MxAccess Toolkit User's Guide — Write() Method, OnWriteComplete Callback sections diff --git a/deprecated/lmxproxy/instances_config.md b/deprecated/lmxproxy/instances_config.md deleted file mode 100644 index 82f82c7..0000000 --- a/deprecated/lmxproxy/instances_config.md +++ /dev/null @@ -1,95 +0,0 @@ -# LmxProxy v2 — Instance Configuration - -Two instances of the LmxProxy v2 Host service are deployed on windev (10.100.0.48), both connecting to the same AVEVA System Platform via MxAccess COM. - -## Instances - -| | Instance A | Instance B | -|---|---|---| -| **Service Name** | `ZB.MOM.WW.LmxProxy.Host.V2` | `ZB.MOM.WW.LmxProxy.Host.V2B` | -| **Display Name** | SCADA Bridge LMX Proxy V2 | SCADA Bridge LMX Proxy V2B | -| **MxAccess Client Name** | `LmxProxy-A` | `LmxProxy-B` | -| **Publish Directory** | `C:\publish-v2\` | `C:\publish-v2b\` | -| **gRPC Port** | 50100 | 50101 | -| **HTTP Status Port** | 8081 | 8082 | -| **Log File Prefix** | `lmxproxy-v2-` | `lmxproxy-v2b-` | -| **Log Directory** | `C:\publish-v2\logs\` | `C:\publish-v2b\logs\` | -| **Health Probe Tag** | `DevPlatform.Scheduler.ScanTime` | `DevPlatform.Scheduler.ScanTime` | -| **API Keys File** | `C:\publish-v2\apikeys.json` | `C:\publish-v2b\apikeys.json` | -| **Auto-Start** | Yes | Yes | - -## Shared API Keys - -Both instances use the same API keys (copied from instance A during setup). - -| Role | Key | -|---|---| -| **ReadWrite** | `c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4` | -| **ReadOnly** | `a77d090d4adcfeaac1a50379ec5f971ff282c998599fd8ccf410090c9f290150` | - -## Service Management - -```bash -# Instance A -sc start ZB.MOM.WW.LmxProxy.Host.V2 -sc stop ZB.MOM.WW.LmxProxy.Host.V2 -sc query ZB.MOM.WW.LmxProxy.Host.V2 - -# Instance B -sc start ZB.MOM.WW.LmxProxy.Host.V2B -sc stop ZB.MOM.WW.LmxProxy.Host.V2B -sc query ZB.MOM.WW.LmxProxy.Host.V2B -``` - -## Health Endpoints - -```bash -# Instance A -curl http://10.100.0.48:8081/api/health -curl http://10.100.0.48:8081/api/status - -# Instance B -curl http://10.100.0.48:8082/api/health -curl http://10.100.0.48:8082/api/status -``` - -## Client Connection - -```csharp -// Instance A -var clientA = new LmxProxyClientBuilder() - .WithHost("10.100.0.48") - .WithPort(50100) - .WithApiKey("c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4") - .Build(); - -// Instance B -var clientB = new LmxProxyClientBuilder() - .WithHost("10.100.0.48") - .WithPort(50101) - .WithApiKey("c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4") - .Build(); -``` - -## Updating Instances - -After code changes, both instances need to be republished separately: - -```bash -# Stop both -sc stop ZB.MOM.WW.LmxProxy.Host.V2 -sc stop ZB.MOM.WW.LmxProxy.Host.V2B - -# Publish -dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2 -dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2b - -# Restore instance-specific config (publish overwrites appsettings.json) -# Instance B needs port 50101/8082 and log prefix lmxproxy-v2b- - -# Start both -sc start ZB.MOM.WW.LmxProxy.Host.V2 -sc start ZB.MOM.WW.LmxProxy.Host.V2B -``` - -**Note:** `dotnet publish` overwrites `appsettings.json` in the output directory with the source copy (which has default ports 50051/8080). After publishing, verify the instance-specific config is correct before starting the service. diff --git a/deprecated/lmxproxy/lib/ArchestrA.MXAccess.dll b/deprecated/lmxproxy/lib/ArchestrA.MXAccess.dll deleted file mode 100755 index 01414a3ecd57eead6a74f9ac3917ed20737d4c2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23112 zcmeHv34DxK_y2uno|#Nmk;ER1*k$p|WVdD~B!mR1HIrnL3`r(tCXo~su~%tpX{*(W zQc6`(wN#Z_skVa@(?i zP)GGZs`bR6K|(CXLNf%pt(C|mUC5~YfP`0dABox(57|aVb_X#Q7RZ<@F>5`&!LO`E zKMeJiG~5+|v4(e{WTFI7Qj4n3XR z8FM2QTB>=n4qA!wn@sw-yo#wH3?CZ$xoOeqY+hp&_t5YH+I?Uw)o3BXv}PG6ceQRZ zPCc(ZAmh}++N(0IA{_NlSsIC2R7|)F;R9uyx>-A0#z|C5{R$aUt*w@ESkJV{GEQBj zy({CMgkO?zFTx*{@dku1CfrBRbV3R4`$RpQ^$~oS8Ra-fZS#YZlf&%IVTBI2$)R$S zuojYp~nvAGeXw1{ME4A;_qXk8^JU(*Jfi8PSRO~6xY zX>9b2bVyShbV2}d>p&LxIh)vzH*XYmkP9!8>F%QLJ@nqad-dIC4*!pSA^T5b+8^am zk?g-?OmpMWX2N9Z;4UpB6fkki1NTz<4jUf%k5e|d;lMuBC_je zXv}vw1HkU{4(5_G=-)@DF?9=&)YmiSJA73zbg0u;a~Z}LY46kwZQ6y;?=H3SCx(-2 z&?1{oRP8un?pP}>)F~HHryNmr%C&cvb79DI+A|RyEk;ADTpv-j%hirc=C)29VjUgz zViMZj9+PD2Y1GK}5Jok?a9{N8pT(&yu*eaoXbb@7ajHtjd7PRq<2+6oWSqw->LdA6&<=_|#g*V?Q3V zsYA$(Ak*RBethAngO)#zhX1brhanNJ=SNzzZDS=CFa1_;0T=dR{(9p~)j z@Hppu&2c8WiE7qTB%OMZ^ha{oLregF2**eB+&qq(IJ9!kQ-EZ-Y&Z1)?RMQ*E2bix zEk~XX6iN6q0b&C{wFDKwsmJV8J5Fu1QyqA@3-)rIc%CT8<+`xtsG%n)(iz05lseQX zJJpNl72Bx+oSI^%9^uqeb}EOt!t82L;m}#g>stdV8eYiM+nn+SMYSK~<(h(`a_4xt zrp(OquJSy8P~@$Pg0%Rv5>EMmQZWe>s$t=r(lZOs8^Nh?R?hP#b1Irm!ibsWvJ~)1 zmt}y@xvT(OK^*m3m(^@&qc$>qf8p;jlFpa|k4l4m28Ww9-K;{(= zcX0R#hZi}#!=cud>iu&|;$7E5&kznrb6CKkg~LZVoW|i394_Q=IfolLe3!$+9A4(| z4-P%tNOLO=qc}_eRI|Zu>jB5RtwwV$aN7#_y4w!G_uO`Y|C!rfz^iTt0A1Zbe5fP_ z5>tAsn{E)@s9QMozOY5~szrS$`ttcplJ5%_#io1~%hWBAdeu?RUDb-wc3aN#0mb75 zFV{lVj?vay&I?rqG1@rG)F@RrLu|KGvs6u46DPG2c@$w}$?d8h{QM|Whg6BIjWh2H z)j&q489DDVDB4-e)OFYr?4(rcaMsmHHBz@?a;zl(daE-T4v2V&N~=TVsz=w#E3ZR6 zsm`sH_eve=UG>;nc_(U9syg+l>eQ#IQ=h6%eX2V3scO~7s5eXO^-W_*EYhXSQM)}4 zt0^8iZyLX=g17d(o^|qM3N4``A7XeOQRIW2vX_%7S?4@Q9wccj(P@ger;5f>UnfOl zDcMQUSnBVjXe^~VDH=-yofM6wK~9Rs(hw&_V`;dPqOml}Nzqsu`7nLKL%0lJAc?De`@ylOo@7n!`gn$#*Qzc8Ywj zbW-Gdm6Iahr#mU~eYTS#-{(0g^8G0%MZPa^Qsnz$Cq=$L@1)50Y9~d$(}}sp_f<}c z{Cm|&k$-EP6#2K-Ns)i+oD})D-bs;v8=MsRx5-J7e_Nas`M1MKk$>+xDe`Z(lOoIZ zIw`VjpOYfX_B$!E?0}OZ%MLmzvg|`AMV1|QQe@dDPKqo$?xe`FlTM0kIpw6tmeWp( zY&qkk$dw_qKz$t-Ea&tr8X&!o$7)1tAC=8IxQDgwsmMDrun?Nn% zR1%xY%VoBcB_HQhxt$_gL}7!SB0ELllAR(SL?Ng>m2=oq!YPL>(>usIDJqJBA72KN z7)3=;o#Z@kMN*2TU15|=m#bvlQ)KUzNl2th5!C6uZ zhYiSm-NBJ9k!!)-*KY7te_fYW%_VE>S1=_zS4Zdl9J{};=YAWys%u)KmkU|g--Wy$ z23*CAfNE9-j-y6d<}W$0Y7)wdqz!sVo0@BLA#MNZqx*X8FZ$>|Ep~s4{rk7czng{s zKK3B`{a-~J1?z;`+97!$`Qsea3CRPu13@?iNx;IGCyQc@SsKnd{z!?eDR#!qkkbr! zL$*OY3T%UT3@4lo;z`^jY!JV~Zh3=v0efHKKLY5>41h;j1?Ld|N7O^7xix@4v!|KX z{WrWxBhGdf=B^NyGk2AXu!MDU_Y$_VUf?WY@$SBWsqRgL_t;Q(f8e9sec5uB2Y8T~ z-P;JCuu4dthn}O@X>7ngMFV>~2pZTP2&iQeU^^BL*ok!m3}U?jC6)jf#*zV}*Z{yD zEFCa`m+i~Trtq?9yle(9o5{al!9&j@&1Kh?Y0=~m4g%Ir3hNwf>XDk8Goi$g-;C5@D zGM2apg45nZ`NQ;G9*i z;P?v8-wntZ!H9mN&ETdG!Y{%&w2aW6Q1 zRC_t+95~S;Q*>u>q86~f7z3Cg#w+?$J1BhF3#xd9I~%Xc0)Ld41AM1y8s|>~ei1lH z?p36-hH9mfV5N#$wHolSayQ^{;@USuq@VK%P@T{^L@RD*j;5Fqrz*|b@K^nAx9%2k&BQXnb zpRyA0u(BHPxN-vvOG@TRWwn5}lrexxF$>T`tOR^nRSo!>YB%6!)j7^lvblmQ zQVTXi=!g^uJQzub6pj>$)D!&PNd1vgkqqGFAQb^0k7Q-DgbJi7NV7oCL7Iy+5A>6e zTa2_C^qWXqfp16JiS!=QUZjKIe~5Gf=_JxWz`Kg{E7ENwPhp1845_6sS7?pY7O5jr zXQZy+>yUaP^+p;5-Y_H+@KU4-;1iLiAWcJpWx_0^c}UAZy@vD#@Qp}sA#Fw4iF6eF zQ%GllpGUd~{0F3ep!9X5-;o5x3_*#cLUK{e5=1&A;GRqV^=0#fr-UV_WtFfFUkV7= zU8rzhrh&|LwKs{n&@Gs^3oh$%{?YGy5_5j|7c>&jQ*a@^Z>x`s;u1r8RZc?a*k$`S&G@u8|0`$VY z3Dutq=)>{={a68DV>T9$?uHepw-}Iae-)_90;t88I11c-mjSlJEs274!0iaFlu>|P za1W)xs?h^5g!KZHSPY;ZA8aYGf}{aPu)$2M#2FAcJp$63{LY!M+4Qjm9)Z0qXA{vQ z)tp;eXzayOQZv&m=JJYkqovGfi7zx*tt{4(Q(&~(EU_V}<*_+AMzF>t*A+}GGZx#d zWBAX*G6xnX7;J|40z+}Wkqh=U6z3KiEf1F;W--}}@#dnELZj{P6)7=V3^tRw_@5}G z|5K~=V+Iz-mFDFcEymo3m|wRgSiP1Bug}0LU$;bPeTKe1gQb?I+YGi+s}qSg*o^sR zONEn>ZZ5Ut7$4G&p(&dU%pPmZv9a_DtIb%%`jnb-V{LdzmtATz+Ljl$us2C%1|z!b3IA)W}Ur zEG{i#PLXR_JB(GBD=rn5AdwrPdN-aV{%y29Q{Ee;FUK z+|Tq<%w#xBp#-hth;+Oq(v3C;J)|TTWq5a#p(6|H(T3bygvkVFbY~Xcs&#p?P@Rxo zBUhKrMeB!hr|^9lvSx>^4>IEcd3Ed^SX|eDf%nz%pj7}a%5QK_Ki)ol*ekNKh zm|->Ih!uIJ{8D*!t)J;^E<<7cEIvcv{e66Mo^67`Vyw>}h`C~MFy)w7^yj z!=`50<_iS!C@i7T`D%nvWbaa1J!Dz?khGWP6arSRy||NxMWtA4Wvea4yhSjUA2sGW zZH+VKJ8?$?Bo0VmMGxFJIhTZ@F}V}s&E|0?BPNl_#*$3MTrrcEEF;Q4bg?QLW8e#O z%~r&Ezc?9Ce-hWKz21bclS6LEyHqC|=H=G2hb7YPf`TR%HF%J3w83QTJUGS!XySOx ze5-wliM3%(G02O{U~C%^>hwF=<4d1O zS+VSLZk5l6f_zatqAC^(P&lp1*X<2 zk?AGV{{9+Qty<3T*Jzc%yc_mVL5YtyhkhJ3!wg_d_WDDrQ>9IPTByk`U*y?3(9eMuO zZHh(PwyvMpXWWO^-Ccac8xPskL|A;-Z`~LF@XJ2i{LSV0pC@%cG1f5XOq0CrDdDb* zJ`G*1h|7DV)z`_hyA1gL;J05rb^6%evaTJI1|NER-v=Ocia^^SJbX>n~ z*{_z9J)4)aycc&LS)ggMbNDMK+}CYAJA3n;H#-ZgU3{n%7p_kV57x)Wg$IX4Mo7W2 zNm6WZXl!U$SZrisTzEn}tQYv{5 zsHpB?Pd-so>(Qg3A4L{N(4I1)a3DCp%N)^xs2qsefw(vjjRV2CUoM4_=0H?3g4dWb zg14(Of~yr75giD6)*(#D2!RcKfKw0Y(!VXqKO{mU#9;7H3r8o!O9|oey5Q*0NL_GP zVpLdgR76r#aHJHiO9<6P#70D5%#mbxWP&6`#6<)r>BAGz7vW*Su~E9j;JEm>*rbFA zDJd~LT9%ab=%3iA$l!?Rc#Pxd$RtQchX%(*=%W)OBBGPxl9FV}`1r(#$fUT);6$hh z4hxNl435_8^uZCjNGU8dE=i}4NRTC?!lLxi(WpHkAp&;BN(sTSp;B0IXmnU)YIlpc63Bua9$_|tfY$w)9ZA&Llc;Lt)&JZ#?oz@{2eN3^3NnFKfMUo+KGBU zj=v48%f@TT=VelEj{gcsSM;Az_lXZfZJCSa*KKX6uiT$ z#MBjFlMPZP@be#_X(X4Ca2Fd1-4gC$vjH)GV5NZfUuJwISA@EYp@qJQ(;~I2l~=;+ zuYk7(E{AL`6U1d4mM1`imE~|dO6u52`Xt;y3r^dHLe>K58ec+jU#v%xc%r`Wg*>%} zjms9J?!sCY#G-r-?yIRz@;w&iQgI(03vLeQ%3t@<8(e{OgI;hpjHb*-UMXkwT%U|0Vg8O$|SwNZwr3Afc zC}jq>6n@xhc})7O_(v9HqeKPzzy!Pm(iYxIx!~mS(nY{*kSKw)7P2OkpiyMurBcvR znW$$le8|Kb=oFrhznA8HRJZ>6JypM-xZ(c}AWi=q`CWEf!R;ZR%WZ zt8$2__Eijz)isiQ)gUy!9>Z|6WV94(;|(Q7ouA}GY!_emgi=ekp}5S1TX9_jXam>H zSDjH{m|!#NT1qX5;qL1vGqmwWi_LlMqH8U+q->>cV|zB;=ve6n5Z680_*kh`6Hgts zn|htZpW#hB^^#5>D(OSRqr;3gSCD1Qk4);yMY81Q&tHL(9wdMqDm0h#&vHdXY_y8-^cBX z_7zVD4C`V(y{*H(c;I*OIoe7wCIpJzV$}kncIU5iu6@$e@zZjl_$Q^ zbDv2!hx_Y9|FaXi1paxWHyB>DINYImcQOV&g+6B*$FD$5=~WLAWPsBsG2%0+V{9 z3tP4pqofEatXf|!%?z;@&ao8MD%({aQMCtDd~670_;_yFUi6UM92O`wk{4-hKsAY& zCu*q^(GA5mQtOo%!@6ct6M5u#lR`dTg7s2ZRIsl0@WA|0PMyk>t}i@UadpZg(%P#9 zZ{7OFwdIb1Rq9cZV^$4b)_C4!{{^lI#lIH)kU2@Z^t)+ZTdTW|xzC#Q(G7p=vhjrt z&G*j#;gM(9>qk6NH+`&Ir5V}f(LcU^wB@jrSB*`-uZYQR|4i|L1Ce8=wb9Ivy?o~6 zu-+{vB+u&7SUq}G)Yt%@6@!{hdu)qTC9c4XpKG7-UPph4`rz)a@M7_cvCp+=o>tG| z|MfW}&tXXyRc8+CqaAZN`=6S_VHij}WCZ@z98Ndo;{_hg2MyL0jV z0~TM|6Bz&AvM&lY&yN{+Wk%HYhWp$8oSXSmBfWXRu;uG#uT-4q(&}*k^Jj8CZ!$+e zV#DYq!(V-|Q<_(kE6H_XU0acy0ZSyqFHJ~jU2yzW+k{hIBlL5J z&JEppGUtQj7rX7>e>v3s(`zeQ{4)35lWz{2+x;tX^`ils+N}>cP_qBs0n<8YZoBVX zGjNIKymu$d+QafZt`df!rP3u28vK0PDX?*GOjLWC8h)4i=SxPZi-X1l zZ)KmOHfu1S;}fhQR(piz6Ft%?tC$&BBWo}jLh6ZjwOOsfIfmeTeMpYcBK08ST8rUQ zsHCgzS{+=wGzEk|DUDF@cW1f2R&Yyt-?YN3pI@ubQ@$LW)1>$4h<7|MNUh0?wqjGM zapi-fU)@y}i-^F=yKjEF3rqRw$!saIe8u#RF6O42zG{EE`q(1n+L;5|FMB>l>3`

%VVy=cm0YKWX^KnUQ7114nx$CdC$eZnzxv{Kjry zx{mk$!@pv4<-~QSU+yg3;?X$p+LRHlJzp5{b>l<6Q;S$gl;^9RpA0&`>6hVagf|*3 z4eFZQ;#9zny?4tO4ca?%W7X_YKlR)^Xn93wV93Pnr?sxW<9`_2Y{bz^uSmm!<|Oyt zess#gUA+Pda?+pQX|k=`-?GP)1?Tp@U0iw$=Az!H_d>_VIIJ zQ)6m}DR*p4rucbGt=5LE4m*c%5CVyNBB_ywfvU z=uM%cyz;Nh|79fH_|CE?15b2Ved&q2S6+Q>MzfSlVH0($+Gc&6`B|v%*6c&8zwcM^ z`kXA;vpXI*swac9gZ26F8GAN_|lKf zmi@9Q@F$nqEnoXFFaG7uV#~W;4I5l|tj~cF!(VIQ64Bt#xhy1wrnaqQ)&k-fHWea~{G@T4OWn$S{4Y_KU3iWCOx><|#{ zXj@!w8=;g8#MFwkr5~zR=TG&(E?-KK+B(9ULTKEC!kZPFom>imIoTBdy5^KvrS?uC zh1AUBfnt^tL!D6bO!xGkDng%qWAe1CA%Pzq+B&n9B~J<@8(NFlVM*HRR@MG9o7)4b zwX9Z+AZRl*%uy+Vc7G`u+U>hE1a05C<#KtX?%)(q{E1>4#=dLT%)Y|R-{PPddn(Zz z3rWqfr-i(u!)vO0^(H!xjU89M>eS>Vh-V8N+&1*ONkLnX!-|!e56?f6IRdV2#KFa-L^P0vy(%G9Of3me; zqAqRs4HvidKgSK~mvG4R(z#=HEakvUw{Mqxn$UdSBTu`p zkM2|XWsGJ2;*;B5E_!Ub86W=Qs_@pUZ87tfn4-6QzA)ySpIzsEe6rD*9r0`SrF?Fl zb@=$jcW-^Vwy))zylsgewb?b}$|gY{kaG8!+BECeVQaqKWx26v&YW%)FLw{{9sESL zYWtaEKg`V-KmCo{Te^7-)Lsi-YHq*w^*K8irzeJWz1H>+V&fx-jj{IF*t7h^?`6qZ zn+%hG`Ki^RnO*8NnKboK-O$rSYJ@?6bC?%>P(v{+NSj=o6XG~A26VmB#@ zO1k^%I^}fM>hV57>yVUHU_XVRr8VHo9kYercWXFXx~D)beFX$vwhG#-h4V9CC{BHHn58(Y2PrxCi~sV4;I9sa&rWN_8ov>= z@l(yd69o$gWwjcA66hUO43lq0KvCE8EX`K{c-H=7T~?$BFLinyCV+vl{jO^5E^%lqYXn@+aKsxah#!;a;5YCqJ3ZfgR`peaN26O2fy&eGr3u%THyq{n-Pb!i_Rn}XaQf&5tM=MdZdae(J11ac z>j~F)9*#eDEoDvbx8_cNBlqWz{o3fN#3vD4=PCq2n!4~`zOhtz-?#Oxxht%mx<_i} zyrFYd>O5*KqN3zqi*na_;pA4c#5TgV!*LnuD(Y}A(`HQSq#-vahYkup|A{pAR7REO zp6yb0ts)+}3~6w6hst(;=k>wzPL&-T^TNJG4Y4}5ufh0^hi_l`rlHp92Ulof6nshz0{-XcK2Fu`_AM!>-UaHzxOXWQ2B1t*T>x*(J6D+1fOhnb^C$ygSH*nvFF{&(cLuf zA1Iyk>wD(J4j$gxxOq!@M>RXWuiSm@64y_k^0|Ja>;q-5&pj7at-R!$p0{oO5Br7< zkizz^|6z0F%imA%Y18bjUN;9XuTr>6RSK7y9#ZS7gx?S`f1zP%`QN^kQFz~XEmUjJ zjY2?j4jrY=-AT9z7;?@Wm97B}L6K4fZe%0%(cxjk?;CQ7RbOq-m^^LBOY;JDY%cML zcq;I@*|lSmeK?H1m$`q*Q&l%U7}rVZaW*HQ(Xs0vCO2)e;j?3-o90%S=8p*4q8Yh5 zU~W~9$J+jI2dD=*e%(JW6_-Tc189`i0p4%=Fsb9A*!(2YZt z{eNn|Hua+T?bQDBdw+9g+^zimuWUX1bJtx3&#T*SasT-Ij1`tWkG))0I^`|1 zOUklw7q8~&t~HO{@BL!nJ6?hBoZatfIlt}MVe2QA-I;qT_StM*z|E@TkFHC~bp1~3 zbhpW}dDb8M{?O~$?9T(QW$xFnTVPun6rg&usb@t&NUyhs`%Dhaj$WZy@?DSIbGOuU kew(j}*l!VDII>as_|Ds}1^T{y=Wx#tgTCq3OMYYVU( Invensys Systems, Inc. © 2002–2010, 2014. Application Server 2014. - -# Welcome - -This guide describes how to use the MXAccess Toolkit. ArchestrA Galaxy data access is exposed to .NET and COM clients through a programmable object model called MXAccess. You can use the MXAccess object model to write programs that automate reading and writing data values in the attributes of ArchestrA objects. - -You can view this document online or you can print it, in part or whole, by using the print feature in Adobe Reader. - -This guide assumes you know how to use Microsoft Windows, including navigating menus, moving from application to application, and moving objects on the screen. If you need help with these tasks, see the Microsoft Help. - -# Documentation Conventions - -This documentation uses the following conventions: - - - - - - - - - - - - - - - - - - - - - - -
ConventionUsed for
Initial CapitalsPaths and file names.
BoldMenus, commands, dialog box names, and dialog box options.
MonospaceCode samples and display text.
- -# Technical Support - -Wonderware Technical Support offers a variety of support options to answer any questions on Wonderware products and their implementation. - -Before you contact Technical Support, refer to the relevant section(s) in this documentation for a possible solution to the problem. If you need to contact technical support for help, have the following information ready: - -* The type and version of the operating system you are using. -* Details of how to recreate the problem. -* The exact wording of the error messages you saw. -* Any relevant output listing from the Log Viewer or any other diagnostic applications. -* Details of what you did to try to solve the problem(s) and your results. -* If known, the Wonderware Technical Support case number assigned to your problem, if this is an ongoing problem. - -# Chapter 1: Getting Started - -Use the MXAccess Toolkit to interface a program to ArchestrA Message Exchange. The Toolkit supports a simple, lightweight interface that permits: - -* Connecting to an ArchestrA Galaxy. -* Subscribing to one or more attributes and receiving data updates from these attributes. -* Writing new values to these attributes. -* Authenticating one or more users so that the program can write to attributes that require secured writes or verified writes. - -At minimum, a deployed ArchestrA Platform must exist on the computer running the client application. - -## Components of the MXAccess Toolkit - -The MXAccess Toolkit contains: - -* The MXAccess32.tlb type library . -* The MxAccess.tlb type library. -* The COM/.NET support files: - * The .NET interop DLL ArchestrA.MxAccess.dll - * The ArchestrA.MxAccess.dll .NET policy file. - * Code samples for Visual Basic, .NET, Visual C++, and C#. -* Documentation. - -The MXAccess Toolkit installs these files to the following locations. - - - - - - - - - - - - - - - - - - - - - - -
FilesLocation
Type libraries32-bit operation systems:
\Program Files\ArchestrA\Framework\bin

64-bit operating systems:
\Program Files (x86)\ArchestrA\Framework\bin
Interop file32-bit operation systems:
\Program Files\ArchestrA\Framework\bin

64-bit operating systems:
\Program Files (x86)\ArchestrA\Framework\bin
Code samples32-bit operating systems:
\Program Files\ArchestrA\Toolkits\Samples

64-bit operating systems:
\Program Files (x86)\ArchestrA\Toolkits\Samples
- -# Registering the Interface Files - -The installation registers the type library and interop file for you. You can use the following procedure to copy these files to a computer and register them yourself. A common example requiring manual registration is running the code samples on an earlier version of Application Server. This is because earlier versions of Application Server did not contain the needed files. - -## To manually register the interface files - -1 Copy the files MxAccess.tlb and ArchestrA.MxAccess.dll as follows: -* On 32-bit operating systems, from the \Program Files\ArchestrA\Framework\Bin folder on the MXAccess Toolkit CD to the folder \Program Files\ArchestrA\Framework\bin on your hard drive. -* On 64-bit operating systems, from the \Program Files (x86)\ArchestrA\Framework\Bin folder on the MXAccess Toolkit CD to the folder \Program Files (x86)\ArchestrA\Framework\bin on your hard drive - -Registering the Interface Files 2 Register the type library. Open a command prompt window and type the regtlib command followed by the complete path to the type library. - -* For example, on a 32-bit operating system: - -``` -regtlib "C:\Program -Files\ArchestrA\Framework\bin\MxAccess32.tlb" -``` - -* For example, on a 64-bit operating system: - -``` -regtlib "C:\Program Files -(x86)\ArchestrA\Framework\bin\MxAccess32.tlb" -``` - -**Note:** You must enclose the folder path within quotation marks because there is a space in the Program Files folder name. - -3 Register the interop file. -a Open two Explorer windows. Browse to the \WINDOWS\assembly folder in one window. In the other window: -* On a 32-bit operating system, browse to the \Program Files\ArchestrA\Framework\bin folder. -* On a 64-bit operating system, browse to the \Program Files (x86)\ArchesrA\Framework\bin folder. -b If an ArchestrA.MxAccess folder exists in the \WINDOWS\assembly folder, you must uninstall it. Do this by right-clicking ArchestrA.MxAccess in the \WINDOWS\assembly folder and then clicking **Uninstall**. Drag the new ArchestrA.MxAccess.dll from the ArchestrA\Framework\bin folder to the WINDOWS\assembly folder. - -# Chapter 2: Using the Interface Files in Programs - -After you install and register the type library and interop, you can use them in your programs. - -To develop new software to use the MXAccess interface, also known as LMXProxy, you must include a reference to the interface in your project. How to do this differs according to which language you are using. This documentation describes using the MXAccess Toolkit with three languages supported by Microsoft Visual Studio: Visual Basic.NET, C#, and Visual C++. - -For the program to connect to the Galaxy and access ArchestrA data, a deployed ArchestrA Platform must exist on the computer running the client application. However, you do not need ArchestrA or a MXAccess runtime license installed on your computer to compile and link the program. - -# Adding a Toolkit Reference Using Visual Basic.NET - -You can add a toolkit reference in Visual Basic.NET. - -## To add a reference - -1. On the **Project** menu, click **Add Reference**. The **Add Reference** dialog box appears. -2. Click the **Browse** tab. -3. Navigate to: - * (32-bit operating system) \Program Files\ArchestrA\Framework\bin and select the ArchestrA.MxAccess.dll interop file. - * (64-bit operating system) \Program Files (x86)\ArchestrA\Framework\bin and select the ArchestrA.MxAccess.dll interop file. - -## To confirm that the MXAccess reference has been added to your project - -1. On the **Project** menu, click ** Properties**, where AppName is the actual name of your project. You can also use Solution Explorer and double-click the My Project icon. -2. Click the **References** tab. The **Reference Names** list includes ArchestrA.MxAccess. -3. Show the path to the DLL file. -4. On the **View** menu, click **Object Browser**. The **Object Browser** appears. -5. Expand the icon for ArchestrA.MxAccess to see all the interfaces and other members provided by the MXAccess interface. - -Adding a Toolkit Reference Using C# # Adding a Toolkit Reference Using C# - -You can add a toolkit reference in C#. - -## To add a reference - -1. On the **Project** menu, click **Add Reference**. The **Add Reference** dialog box appears. -2. Click the **Browse** tab. -3. Navigate to: - * (32-bit operating system) \Program Files\ArchestrA\Framework\bin and select the ArchestrA.MxAccess.dll interop file. - * (64-bit operating system) \Program Files(x86)\ArchestrA\Framework\bin and select the ArchestrA.MxAccess.dll interop file. - -## To confirm that the MXAccess reference has been added to your project - -1. Open Solution Explorer and expand the **References** icon. -2. On the **View** menu, click **Object Browser**. The **Object Browser** pane appears. -3. You can expand the icon for ArchestrA.MxAccess to see all the interfaces and other members provided by the MXAccess interface. - -# Adding a Toolkit Reference Using C++ - -You can add a toolkit reference in C++. - -**Note:** Visual C++ supports smart pointers. When used with COM interface pointers, smart pointers can be a powerful way to ensure that COM objects get properly instantiated at startup and cleaned up at shutdown. The code examples in this toolkit include one C++ project that uses smart pointers (the ActiveX dialog box) and another that makes it clear where the COM objects are being created and destroyed. For more information on using smart pointer templates in your C++ program, see the Visual Studio documentation. - -## To add a reference - -1. In the file stdafx.h, add a line such as the following example to import the type library: - -```c++ -#import "C:\Program Files\ArchestrA\Framework\bin\MxAccess32.tlb" no_namespace raw_interfaces_only -``` - -When you compile, Visual Studio creates a file named MxAccess.tlh. - -2. Open this file to see the interfaces and other members provided by the MXAccess interface. - -# Handling Exceptions in Your API Code - -Include exception handling when any of the API calls are invoked. - -* **Visual Basic.NET** - Add an OnError statement and handling to your functions and subroutines. -* **C#** - Enclose your code in a try block and provide error handling and display in a catch block. -* **Visual C++** - Enclose your code in a try block and provide error handling and display in a catch block. You should also inspect the HRESULT returned from the API functions and add code to handle failures. - -Declaring and Instantiating the LMXProxyServer Object The code examples show you the basic operations of using the MXAccess API function calls. Although they include rudimentary exception handling, they do not handle all possible errors and exceptions. Your program should include exception and error processing to provide robust operation and problem diagnostics appropriate for your specific application. - -# Declaring and Instantiating the LMXProxyServer Object - -To use the MXAccess Toolkit, your program must declare the LMXProxyServer object. Use the following examples, depending on which language you use. - -## Examples - -[Visual Basic.NET] -```vb -Dim WithEvents LMX_Server As ArcestrA.MxAccess.LMXProxyServer -``` - -[C#] -```csharp -ArcestrA.MxAccess.LMXProxyServer LMX_Server; -``` - -[Visual C++] -```cpp -ILMXProxyServer* pLMX_Server; -``` -or, if using a smart pointer -```cpp -CComPtr pLMX_Server; -``` -Create an instance of the LMXProxyServer. Use the following examples, depending on which language you use. - -## Examples - -[Visual Basic.NET] -```vb -LMX_Server = New ArcestrA.MxAccess.LMXProxyServer -``` - -[C#] -```csharp -LMX_Server = new ArcestrA.MxAccess.LMXProxyServer; -``` - -[Visual C++] - -```c++ -HRESULT hr = CoCreateInstance (__uuidof(LMXProxyServer), - NULL, - CLSCTX_INPROC_SERVER, - __uuidof(ILMXProxyServer), - (void **)&pLMX_Server); -``` - -or, if using a smart pointer - -```c++ -HRESULT hr = - pLMX_Server.CoCreateInstance (__uuidof( LMXProxyServer )); -``` - -In Visual C++ your program must initialize COM before it can perform any operations with COM. Most likely, you will create the LMXProxy in the main program thread. For example: - -```c++ -HRESULT hr = - CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); -``` - -initializes COM for the apartment threaded model. Be sure to call CoUninitialize() when your program exits. - -Use the following examples at the end of your program to discard the LMXProxyServer. - -[Visual Basic.NET] - -```vb -LMX_Server = Nothing -``` - -(or simply let it go out of scope) - -[C#] - -```csharp -LMX_Server = null; -``` - -(or simply let it go out of scope) - -[Visual C++] - -```c++ -HRESULT hr = pLMX_Server->Release(); -pLMX_Server = NULL; -``` - -Or, if using a smart pointer, you can simply let it go out of scope. - -Connecting Handlers for Events Reported from the LMXProxyServer # Connecting Handlers for Events Reported from the LMXProxyServer - -The MXAccess Toolkit supports receiving notifications from the LMXProxyServer. In COM, these notifications are handled as connection points. In .NET, these are handled as delegates. Your program must implement a handler for each type of event you expect to receive and connect it to the events using the mechanisms appropriate for the programming language you are using. - -Visual Basic and C# make this implementation fairly easy. Visual C++ requires a little more setup to create a COM object that exposes the necessary interface, including support for the IDispatch interface. - -The events that must be handled are: - -* **OnDataChange()** - Reports the value and status of an ArcestrA Attribute to which the program has subscribed. -* **OnWriteComplete()** - Reports the status after a write operation completes, indicating such information as whether the write operation succeeded or failed. -* **OperationComplete()** - Reports the status when an Advanced Communication Management operation has been completed, indicating such information as whether it succeeded or failed. -* **OnBufferedDataChange()** - Reports the VTQ buffer and the status of an ArcestrA Attribute to which the program has subscribed. - -The specifics of what your event handlers do depend on the purpose of your program. This section focuses primarily on how to connect the handlers in your program to the event callback mechanism. For more information about what should be inside the code for the event handlers, see "Handling the OnDataChange Callback" on page 62, see "Handling the OnWriteComplete Callback" on page 72, see "Handling the OperationComplete Callback" on page 41, and see "Handling the OnBufferedDataChange Callback" on page 63. - -Handling events involves implementing required methods, exposing them as event handlers, and connecting the event handlers to the LMXProxyServer's Connection Points. - -[Visual Basic.NET] - -Declare the methods as: - -```vbnet -Private Sub LMX_OnDataChange( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Integer, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OnDataChange -``` - -and - -```vbnet -Private Sub LMX_OnWriteComplete( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OnWriteComplete -``` - -and - -```vbnet -Private Sub LMX_OperationComplete( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OperationComplete -``` - -and - -```vbnet -Private Sub LMX_OnBufferedDataChange( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal dtDataType As ArchestrA.MxAccess.MxDataType, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Object, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OnBufferedDataChange -``` - -Connecting Handlers for Events Reported from the LMXProxyServer At run time, Visual Basic automatically connects the event handlers when you instantiate the LMX_Server object. - -**Note:** The names do not have to be LMX_OnDataChange, LMX_OnWriteComplete, and LMX_OperationComplete, but the parameter types in the function syntax must match declarations in the LMXProxy interface. - -[C#] - -Declare the methods as: - -```csharp -private void LMX_OnDataChange( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` -and - -```csharp -private void LMX_OnWriteComplete( - int hLMXServerHandle, - int phItemHandle, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` -and - -```csharp -private void LMX_OperationComplete( - int hLMXServerHandle, - int phItemHandle, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` -and - -```csharp -private void LMX_OnBufferedDataChange( - int hLMXServerHandle, - int phItemHandle, - ArchestrA.MxAccess.MxDataType dtDataType, - object pvItemValue, object pvItemQuality, - object pvItemTimeStamp, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` - -At run time, C# does not automatically connect these event handlers for you. You must do this in your code. After you have instantiated the LMX_Server object, the following three lines will connect the methods as delegates for the object: - -```csharp -LMX_Server.OnDataChange += new - _ILMProxyServerEvents_OnDataChangeEventHandler(LMX_OnDataChange); -LMX_Server.OnWriteComplete += new - _ILMProxyServerEvents_OnWriteCompleteEventHandler(LMX_OnWriteComplete); -LMX_Server.OperationComplete += new - _ILMProxyServerEvents_OperationCompleteEventHandler(LMX_OperationComplete); -LMX_Server.OnBufferedDataChange += new - _ILMProxyServerEvents2_OnBufferedDataChangeEventHandler(LMX_OnBufferedDataChange); -``` - -**Note:** The names do not have to be LMX_OnDataChange, LMX_OnWriteComplete, and LMX_OperationComplete, but the parameter types in the function syntax must match declarations in the LMXProxy interface. - -[Visual C++] - -Declare the methods as: - -```cpp -HRESULT CLMX_Events_Shim::OnDataChange ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - VARIANT pvItemValue, - long pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAItemStatus -); -``` -and - -```cpp -HRESULT CLMX_Events_Shim::OnWriteComplete ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - SAFEARRAY *pSAWriteItemStatus -); -``` -and - -```cpp -HRESULT CLMX_Events_Shim::OperationComplete ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - SAFEARRAY *pSAWriteItemStatus); -``` - -Connecting Handlers for Events Reported from the LMXProxyServer and - -```c -HRESULT CLMX_Events_Shim::OnBufferDataChange( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - ArchestrA.MxAccess.MxDataType dtDataType, - VARIANT pvItemValue, - VARIANT pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAItemStatus); -``` - -where CLMX_Events_Shim is a class derived from the base class LMXProxyServerEvents. - -The sample code includes a header file and implementation of CLMXProxyServerEvents that is derived from IDispatch and handles passing IDispatch methods to the OnDataChange(), OnWriteComplete(), and OperationComplete() methods. - -At run time, C++ does not automatically connect these event handlers for you. You must do this in your code. After you have instantiated the LMX_Server object, you must instantiate the CLMX_Events_Shim object. It helps to have two methods in CLMX_Events_Shim that encapsulate the Connect() and Disconnect() operations: - -```c -void CLMX_Events_Shim::Connect() -{ - // check whether we are already connected - if (!m_bConnected) - { - // attempt to call Advise on the - // IConnectionPoint - DWORD cookie = 0; - IConnectionPointContainer* pcPC = 0; - HRESULT hr = pLMX_Server->QueryInterface(&pcPC); - if (SUCCEEDED(hr)) - { - hr = pcPC->FindConnectionPoint( - __uuidof(_ILMXProxyServerEvents), &m_pCP); - if (SUCCEEDED(hr)) - { - hr = m_pCP->Advise(this, &cookie); - m_LmxInterfaceCallBacksCookie = cookie; - m_bConnected = true; - } - pcPC->Release(); - } - } -} -``` - -and - -```c++ -void CLMX_Events_Shim::Disconnect() -{ - // If we are connected then call Unadvise on - // the IConnectionPoint - if (m_bConnected) - { - m_bConnected = false; - IConnectionPoint* pConnectionPoint = m_pCP; - m_pCP->Unadvise(m_LmxInterfaceCallBacksCookie); - pConnectionPoint->Release(); - } -} -``` - -After you instantiate the LMXProxyServer object, make sure that you also instantiate an object of type CLMX_Events_Shim and then call its Connect() method. When you are preparing to delete (or even Unregister) the LMXProxyServer object, you should call the Disconnect() method to stop servicing the event callbacks. - -**Note:** The class names do not have to be CLMX_Events_Shim and CLMXProxyServerEvents but the overall organization of these classes should be similar to the code samples. Because the methods are exposed directly as a COM interface, the names DO have to be OnDataChange, OnWriteComplete, and OperationComplete and the parameter types in the function syntax must match declarations in the LMXProxy interface. - -# Initializing the LMXProxyServer Connection - -The LMXProxyServer must be initialized prior to making other method calls on the COM object's interface. Use the Register() method, which connects your component to LMX. - -## Register() Method - -Connects your component to LMX. - -### Syntax - -**[Visual Basic.NET]** -```vb -Function Register (ByVal pClientName As String) As Integer -``` -**[C#]** -```csharp -int Register(string pClientName); -``` -**[Visual C++]** -```cpp -HRESULT __stdcall Register(BSTR pClientName, long *phLMXServerHandle); -``` - -Shutting Down the LMXProxyServer Connection **Parameters** - -pClientName -Name of your component. - -**Returns** - -Handle of the connection. The handle that is returned represents the LMX connection. Your program should preserve this handle for the life time of the connection because it is used on all subsequent calls to other methods on the interface. - -**Examples** - -[Visual Basic.NET] -```vbnet -hLMX = LMX_Server.Register("TestApp") -``` -[C#] -```csharp -hLMX = LMX_Server.Register("TestApp"); -``` -[Visual C++] -```cpp -BSTR bstrTemp = ::SysAllocString(L"TestApp"); -hr = pLMX_Server->Register(bstrTemp, &hLMX); -::SysFreeString(bstrTemp); -``` - -# Shutting Down the LMXProxyServer Connection - -The LMXProxyServer connection must be released when your application shuts down or when it no longer needs to be connected to the Galaxy. Use the Unregister() method, which disconnects your component from LMX. - -Before calling the Unregister() method, you must unadvise all active items and remove them from the internal tables using the UnAdvise() and RemoveItem() methods. - -## Unregister() Method - -Disconnects your component from LMX. - -**Syntax** - -[Visual Basic.NET] -```vbnet -Sub Unregister (ByVal hLMXServerHandle As Integer) -``` -[C#] -```csharp -void Unregister(int hLMXServerHandle); -``` -[Visual C++] -```cpp -HRESULT __stdcall Unregister(long hLMXServerHandle); -``` - -**Parameters** - -* **hLMXServerHandle** - Handle of the connection. - -**Returns** - -Nothing. - -**Remarks** - -After calling the Unregister() method, you can discard the LMXProxyServer object. If your program is one that connects to LMX for a time, disconnects, and connects again, you may want to keep the LMXProxyServer object, rather than releasing it and creating a new one each time. - -**Examples** - -[Visual Basic.NET] -```vbnet -Call LMX_Server.Unregister (hLMX) -``` -[C#] -```csharp -LMX_Server.Unregister (hLMX); -``` -[Visual C++] -```cpp -hr = pLMX_Server->Unregister (hLMX); -``` - -# Adding Item References to the Internal Tables - -To access an ArchestrA Attribute, you must first add it to the internal table of attribute references that LMX maintains for your connection. Use the AddItem() or the AddItem2() method. - -## AddItem() Method - -Adds an attribute to the internal table of attributes. - -**Syntax** - -[Visual Basic.NET] -```vbnet -Function AddItem ( - ByVal hLMXServerHandle As Integer, - ByVal strItemDef As String -) -As Integer -``` - -Adding Item References to the Internal Tables [C#] - -```csharp -int AddItem( - int hLMXServerHandle, string strItemDef -); -``` - -[Visual C++] - -```cpp -HRESULT __stdcall AddItem( - long hLMXServerHandle, - BSTR strItemDef, - long *phItem -); -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**strItemDef** -Reference string for the attribute. - -**Returns** - -Handle of the item. The handle that is returned represents the item in the LMX connection's internal table. Your program should retain this handle because it is used on all subsequent calls to other methods involved in reading and writing data for that attribute. - -**Remarks** - -You can add several attributes to the table by calling the AddItem() method for each one. - -Before you can write to an attribute or receive data update notifications for it, you must put the item on advise. For more information, see "Advise() Method" on page 33. - -When shutting a program down, you should unadvise all active items and remove all items from the internal table before calling the Unregister() method. For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** - -[Visual Basic.NET] - -```vbnet -hItem = LMX_Server.AddItem(hLMX, strItemDef) -``` - -[C#] - -```csharp -hItem = LMX_Server.AddItem(hLMX, strItemDef); -``` - -[Visual C++] - -```c++ -BSTR bstrTemp = ::SysAllocString(szItemDef); - hr = pLMX_Server->AddItem(hLMX, bstrTemp, &hItem); -::SysFreeString (bstrTemp); -``` - -If the attribute represents a single data value, reference the item as follows: - -[Single data value] -ud1.x - -[Single element of an array:] -ud1.ar[2] - -To reference all elements of an attribute that is an array, follow the reference with brackets: - -[Entire array:] -ud1.ar[ ] - -# AddItem2() Method - -Adds an attribute to the internal table of attributes, using a relative reference and an Object name. This makes it easier to resolve a reference for an attribute relative to an indicated Object. - -The reference string must use one of the five relative reference names: - -* MyPlatform -* MyEngine -* MyArea -* MyContainer -* Me - -The context string identifies the Object to which the reference corresponds. - -## Syntax - -[Visual Basic.NET] - -```vb -Function AddItem2 ( - ByVal hLMXServerHandle As Integer, - ByVal strItemDef As String - ByVal strItemCtxt As String -) -As Integer -``` - -Adding Item References to the Internal Tables [C#] - -```csharp -int AddItem2( - int hLMXServerHandle, - string strItemDef - string strItemCtxt -); -``` - -[Visual C++] - -```cpp -HRESULT __stdcall AddItem2 ( - long hLMXServerHandle, - BSTR strItemDef, - BSTR strItemCtxt, - long *phItem -); -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**strItemDef** -Reference string for the attribute. - -**strItemCtxt** -Context string for the related object. - -**Returns** - -Handle of the item. The handle that is returned represents the item in the LMX connection's internal table. Your program should retain this handle because it is used on all subsequent calls to other methods involved in reading and writing data for that attribute. - -**Remarks** - -You can add several attributes to the table by calling the AddItem() method for each one. - -Before you can write to an attribute or receive data update notifications for it, you must put the item on advise. For more information, see "Advise() Method" on page 33. - -When shutting the program down, you should unadvise all active items and remove all items from the internal table before calling the Unregister() method. For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -The AddItem2() method is on the ILMXProxyServer4 interface, which extends the ILMXProxyServer interface. - -## Examples - -[Visual Basic.NET] -```vb -hItem = LMX_Server.AddItem2(hLMX, strItemDef, strItemCtxt) -``` - -[C#] -```csharp -hItem = LMX_Server.AddItem2(hLMX, strItemDef, strItemCtxt); -``` - -[Visual C++] -```cpp -BSTR bstrTemp = ::SysAllocString(szItemDef); -BSTR bstrCtxt = ::SysAllocString(szItemCtxt); -hr = pLMX_Server->AddItem2(hLMX, bstrTemp, bstrCtxt, -&hItem); -::SysFreeString(bstrTemp); -::SysFreeString(bstrCtxt); -``` - -If the Attribute belongs to the Platform on which an Object is running, you do not need to know the actual name of the Platform. You can add an item using MyPlatform in the reference string, and specify the Object as the context for the reference, using its global name. Other relative references can be specified in the same way: - -* CPULoad on the Platform on which AnalogDevice_001 is running: - * hItem = LMX_Server.AddItem2(hLMX, "MyPlatform.CPULoad", "AnalogDevice_001"); -* Scan period of the Engine on which ud1 is hosted: - * hItem = LMX_Server.AddItem2(hLMX, "MyEngine.Scheduler.ScanPeriod", "ud1"); - -## AddBufferedItem() Method - -You can configure your client program to subscribe to buffered attributes. Buffered attributes support retrieving and processing of multiple data items which are received from field devices during AppEngine's Scan Period. The multiple data items are received by buffered attributes once per scan period in the form of buffer, in order to eliminate data folding. - -### Syntax - -[Visual Basic.NET] -```vb -Function AddBufferedItem( - hLMXServerHandle As Integer, - strItemDef As String, - strItemCtxt As String -) - As Integer - -Adding Item References to the Internal Tables [C#] - -```csharp -int AddBufferedItem( - int hLMXServerHandle, - string strItemDef, - string strItemCtxt) -``` - -[Visual C++] - -```cpp -HRESULT __stdcall AddBufferedItem ( - long hLMXServerHandle, - BSTR strItemDef, - BSTR strItemCtxt, - long * phItem ) -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**strItemDef** -Reference string for the attribute. - -**strItemCtxt** -Context string for the related object. - -**Returns** - -Handle of the item. The handle that is returned represents the item in the LMX connection's internal table. Your program should retain this handle because it is used on all subsequent calls to other methods involved in reading and writing data for the buffered attribute. - -**Remarks** - -You can add several attributes to the table by calling the AddBufferedItem() method for each one. - -Before you can write to an attribute or receive data update notifications for it, you must put the item on advise. For more information, see "Advise() Method" on page 33. - -When shutting a program down, you should unadvise all active items and remove all items from the internal table before calling the Unregister() method. For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** - -[Visual Basic.NET] - -```vbnet -hItem = LMX_Server.AddBufferedItem(hLMX, strItemDef, strItemCtxt) -``` - -[C#] -```csharp -hItem = LMX_Server.AddBufferedItem(hLMX, strItemDef, - strItemCtxt); -``` - -[Visual C++] -```cpp -LMX_Server.AddBufferedItem(hLMXServerHandle, strItemDef, strItemCtxt, phItem); -``` - -# Removing Item References from Internal Tables - -When you no longer need access to a particular attribute, you can remove the reference to it from the internal table. Use the RemoveItem() method. - -## RemoveItem() Method - -Removes an attribute from the internal table of attributes. - -### Syntax - -[Visual Basic.NET] -```vbnet -Sub RemoveItem ( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer -) -``` - -[C#] -```csharp -void RemoveItem( - int hLMXServerHandle, - int hItem -); -``` - -[Visual C++] -```cpp -HRESULT __stdcall RemoveItem( - long hLMXServerHandle, - long hItem -); -``` - -### Parameters - -**hLMXServerHandle** - Handle of the connection. - -**hItem** - Handle of the item. - -Putting Items on Advise **Returns** -Nothing. - -**Remarks** -If the item is active—that is, if it has been placed on advise—unadvise the item before removing it. For more information, see "UnAdvise() Method" on page 36. -When shutting down, unadvise all active items and remove all items from the internal table before calling the Unregister() method. For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** -[Visual Basic.NET] -Call LMX_Server.RemoveItem(hLMX, hItem) -[C#] -LMX_Server.RemoveItem(hLMX, hItem); -[Visual C++] -hr = pLMX_Server->RemoveItem(hLMX, hItem); - -# Putting Items on Advise - -To receive data updates regarding an attribute, you can put it on advise, also known as subscribing to an item. Use the Advise() method or the AdviseSupervisory() method. - -## Advise() Method - -Adds advise to an attribute. - -### Syntax -[Visual Basic.NET] -```vbnet -Sub Advise( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer -) -``` -[C#] -```csharp -void Advise( - int hLMXServerHandle, - int hItem -); -``` - -[Visual C++] - -```c++ -HRESULT __stdcall Advise( - long hLMXServerHandle, - long hItem -); -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**hItem** -Handle of the item. - -**Returns** - -Nothing. - -**Remarks** - -After you have put an item on advise, it is considered active. If the data of that attribute changes, your program receives a notification of the update through the OnDataChange() event. - -You must put an item on advise before you can write to it. - -You must call the AddItem() method on an item before you can put it on advise. When shutting down the program, you should unadvise all active items and remove all items from the internal table before calling Unregister(). For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** - -[Visual Basic.NET] - -```vb -Call LMX_Server.Advise(hLMX, hItem) -``` - -[C#] - -```csharp -LMX_Server.Advise(hLMX, hItem); -``` - -[Visual C++] - -```c++ -hr = pLMX_Server->Advise(hLMX, hItem); -``` - -Putting Items on Advise # AdviseSupervisory() Method - -Adds advise to an attribute. - -## Syntax - -**[Visual Basic.NET]** - -```vbnet -Sub AdviseSupervisory( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer -) -``` - -**[C#]** - -```csharp -void AdviseSupervisory( - int hLMXServerHandle, - int hItem -); -``` - -**[Visual C++]** - -```cpp -HRESULT __stdcall AdviseSupervisory( - long hLMXServerHandle, - long hItem -); -``` - -## Parameters - -**hLMXServerHandle** -Handle of the connection. - -**hItem** -Handle of the item. - -## Returns - -Nothing. - -## Remarks - -The Advise() method establishes a User Connection to ArchestrA data, while the AdviseSupervisory() method establishes a Supervisory Connection. Both of these connections provide updates via the OnDataChange() callback and status information via the OnWriteComplete() and OperationComplete() callbacks. When you attempt to invoke a Write operation on the item, the Supervisory Connection does a Supervisory Set on the Attribute; it does not require a login by a user. - -The AdviseSupervisory() method is on the ILMXProxyServer4 interface, which extends the ILMXProxyServer interface. - -The Supervisory Connection is appropriate if your application runs in the background, such as a Windows Service, which normally does not interact with users via a GUI. Using AdviseSupervisory() for the items enables the program to write to items that require Secured Writes or Verified Writes, without requiring a user login to permit the write. - -You must put an item on advise before you can write to it. - -You must call the AddItem() method on an item before you can put it on advise. When shutting down the program, you should unadvise all active items and remove all items from the internal table before calling Unregister(). For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** - -[Visual Basic.NET] -```vb -Call LMX_Server.AdviseSupervisory(hLMX, hItem) -``` -[C#] -```csharp -LMX_Server.AdviseSupervisory(hLMX, hItem); -``` -[Visual C++] -```cpp -hr = pLMX_Server->AdviseSupervisory(hLMX, hItem); -``` - -## Unadvising Items - -When your program no longer needs to receive data updates and no longer needs to write to an attribute, it can unadvise the item—that is, it can take it off advise. Use the UnAdvise() method. - -## UnAdvise() Method - -Stops the advise from the attribute. - -### Syntax - -[Visual Basic.NET] -```vb -Sub UnAdvise( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer -) -``` -[C#] -```csharp -void UnAdvise ( - int hLMXServerHandle, - int hItem -); -``` - -Unadvising Items [Visual C++] - -```c++ -HRESULT __stdcall UnAdvise ( - long hLMXServerHandle, - long hItem -); -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**hItem** -Handle of the item. - -**Returns** - -Nothing. - -**Remarks** - -When shutting down, unadvise all active items and remove all items from the internal table before calling the Unregister() method. For more information, see "UnAdvise() Method" on page 36 and "RemoveItem() Method" on page 32. - -**Examples** - -**[Visual Basic.NET]** - -```vb -Call LMX_Server.UnAdvise(hLMX, hItem) -``` - -**[C#]** - -```csharp -LMX_Server.UnAdvise(hLMX, hItem); -``` - -**[Visual C++]** - -```cpp -hr = pLMX_Server->UnAdvise(hLMX, hItem); -``` - -# Advanced Communication Management - -After an attribute has been put on advise, the application can call Suspend() to temporarily stop receiving updates without tearing down the subscription to the attribute. Updates can be resumed by calling Activate(). The callback OperationComplete() returns the status information about processing these function calls. - -The Suspend() and Activate() methods are on the ILMXProxyServer4 interface, which extends the ILMXProxyServer interface. - -When an item is suspended or activated, that item should not be taken off advise or removed from the internal tables until the OperationComplete() event is received, indicating whether the update to the Advanced Communication Management status is successful or an error has occurred. - -If the event indicates that an OperationComplete error has been detected, check the category and error code for further information. - -To suspend updates, call the Suspend() method. - -## Suspend() Method - -Temporarily stops receiving updates without removing the subscription to the attribute. - -### Syntax - -**[Visual Basic.NET]** - -```vbnet -Sub Suspend(ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByRef pMxStatus As ArcestrA.MxAccess.MxStatus) -``` - -**[C#]** - -```csharp -void Suspend(int hLMXServerHandle, - int hItem, - out ArcestrA.MxAccess.MxStatus pMxStatus); -``` - -**[Visual C++]** - -```cpp -HRESULT Suspend(long hLMXServerHandle, - long hItem, - MxStatus *pMxStatus); -``` - -### Parameters - -**hLMXServerHandle** -Handle of the connection - -**hItem** -Handle of the item - -Advanced Communication Management **Returns** - -MxStatus indicating success, and whether operation is pending. - -**Remarks** - -If the returned MxStatus indicates an error, it may be because the handle to the LMXServer or the handle to the item is not registered. If it is successful, check the category of the returned MxStatus, as the operation may still be pending. Upon completion, an OperationComplete event will be received, indicating whether the operation has completed successfully or there is a problem. - -To resume updates, call the Activate() method. - -**Examples** - -[Visual Basic.NET] - -Call LMX_Server.Suspend(hLMX, hItem, hItemStatus) - -[C#] - -LMX_Server.Suspend(hLMX, hItem, out ItemStatus); - -[Visual C++] - -hr = pLMX_Server->Suspend(hLMX, hItem, &ItemStatus); - -## Activate() Method - -Starts receiving updates without removing the subscription to the attribute. - -**Syntax** - -[Visual Basic.NET] - -```vbnet -Sub Activate(ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByRef pMxStatus As ArcestrA.MxAccess.MxStatus) -``` - -[C#] - -```csharp -void Activate(int hLMXServerHandle, - int hItem, - ArcestrA.MxAccess.MxStatus pMxStatus); -``` - -[Visual C++] - -```cpp -HRESULT Activate(long hLMXServerHandle, - long hItem, - MxStatus *pMxStatus); -``` - -**Parameters** - -* **hLMXServerHandle** - Handle of the connection -* **hItem** - Handle of the item - -**Returns** - -MxStatus indicating success, and whether operation is pending. - -**Remarks** - -If the returned MxStatus indicates an error, it may be because the handle to the LMXServer or the handle to the item is not registered. If it is successful, check the category of the returned MxStatus, as the operation may still be pending. Upon completion, an OperationComplete event will be received, indicating whether the operation has completed successfully or there is a problem. - -When either Suspend() or Activate() is called, the completion of the status change may take some time to process. When the processing is finished, an OperationComplete event is received, indicating which item has been suspended or activated, and whether the operation was successful. - -**Examples** - -[Visual Basic.NET] -```vbnet -Call LMX_Server.Activate(hLMX, hItem, ItemStatus) -``` -[C#] -```csharp -LMX_Server.Activate(hLMX, hItem, out ItemStatus); -``` -[Visual C++] -```cpp -hr = pLMX_Server->Activate(hLMX, hItem, &ItemStatus); -``` - -Handling the OperationComplete Callback # Handling the OperationComplete Callback - -The LMXProxyInterface emits an event for OperationComplete when your program invokes a Suspend() or Activate() function. The information your program receives identifies whether the change was successful and any error code information that may be appropriate. - -## OperationComplete Event - -Reports the status of an operation on an Attribute subscription, such as Suspend or Activate. - -### Syntax - -**[Visual Basic.NET]** - -```vbnet -Private Sub LMX_OperationComplete( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Integer, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) -Handles LMX_Server.OperationComplete -``` - -**[C#]** - -```csharp -private void LMX_OperationComplete( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[] ItemStatus -); -``` - -**[Visual C++]** - -```cpp -HRESULT CLMX_Events_Shim::OperationComplete( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - VARIANT pvItemValue, - long pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAItemStatus -); -``` - -**Parameters** - -* **hLMXServerHandle** - Handle of the connection. -* **phItemHandle** - Handle of the item. -* **ItemStatus** - Array of Message Exchange statuses. - -**Returns** - -Nothing. - -**Remarks** - -For more information, including the function prototypes for the different language, see "Connecting Handlers for Events Reported from the LMXProxyServer" on page 19. - -Interpreting the OperationComplete status is very much like interpreting the data change status and involves examining the contents of the MxStatus structure. If the success member of the status is true, the Suspend() or Activate() operation has been accepted and completed. If the success member is false, check the other structure members for additional information about why the operation failed. - -**Examples** - -**[Visual Basic.NET]** - -```vbnet -If ItemStatus(0).success Then - CommunicationManagement_Status.Text = "Operation Complete - status OK" -Else - CommunicationManagement_Status.Text = "Operation Complete w/error - cat: " & ItemStatus(0).category & " Src: " & ItemStatus(0).detectedBy & " detail: " & ItemStatus(0).detail -End If -``` - -**[C#]** - -```csharp -if (ItemStatus[0].success != 0) { - CommunicationManagement_Status.Text = "Operation Complete - status OK"; -} else { - CommunicationManagement_Status.Text = "Operation Complete w/error - cat: " + ItemStatus[0].category + " Src: " + ItemStatus[0].detectedBy + " detail: " + ItemStatus[0].detail; -} -``` - -Writing Data Values [Visual C++] - -```c -if (ItemStatus[0].success != 0) { - SetDlgItemText(hMainDlg, IDC_CommunicationManagement_Status, - L"Operation Complete - status OK"); -} else { - TCHAR error_text[256]; - swprintf_s(error_text, - sizeof(error_text)/sizeof(TCHAR), L"Operation Complete - w/error - cat: %d Src: %d detail: %d", - ItemStatus[0].category, ItemStatus[0].detectedBy, - ItemStatus[0].detail); - SetDlgItemText(hMainDlg, - IDC_CommunicationManagement_Status, error_text); -} -``` - -# Writing Data Values - -To write data to an attribute, call the Write() method or the WriteSecured() method. - -To write data to an attribute, call the Write() method or the WriteSecured() method. - -To write data and a specific timestamp to an attribute, call the Write2() method or the WriteSecured2() method. - -To write to an item, the data must be in an object (or in C++ a VARIANT) of the proper internal type—integer, double, string, or other supported data types. - -String data can be used to write to an attribute of any data type, so long as the string can be converted to the appropriate data type. Otherwise, the MxStatus returned by the OnWriteComplete() event indicates an error. For example, writing "1/27/2014 03:43:02 PM" to an integer value generates an error. - -# Write() Method - -Writes data to an attribute. - -## Syntax - -[Visual Basic.NET] - -```vb -Sub Write( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByVal pItemValue As Object, - ByVal UserID As Integer -) -``` - -[C#] - -```csharp -void Write( - int hLMXServerHandle, - int hItem, - object pItemValue, - int UserID -); -``` - -[Visual C++] - -```cpp -HRESULT __stdcall Write( - long hLMXServerHandle, - long hItem, - VARIANT pItemValue, - long UserID -); -``` - -**Parameters** - -**hLMXServerHandle** -The handle of the connection. - -**hItem** -The handle of the item. - -**pItemValue** -The new data value. - -**UserID** -The ID "cookie" for the user. If ArchestrA security is not enabled, this value is -1 or 0. - -**Returns** - -None. - -**Remarks** - -Before you can write to an attribute, you must add the item to the internal table and put it on advise. For more information, see "AddItem() Method" on page 26 and "Advise() Method" on page 33. - -Upon completion of the write, your program receives notification of the success/failure status through the OnWriteComplete() event. - -When an item is written, that item should not be taken off advise or removed from the internal tables until the OnWriteComplete() event is received, indicating whether the write is successful or an error has occurred. - -Writing Data Values If the event indicates that an OnWriteComplete error has been detected, check the error code. - -* A code of 1008 indicates that the user does not have the proper security to write to this item. -* A code of 1012 indicates that a secured write is required. -* A code of 1013 indicates that a verified write is required. - -For more information, see "WriteSecured() Method" on page 47 and "AuthenticateUser() Method" on page 54. Also check the documentation on LMX details for other possible error codes. - -If a secured write or verified write is required, call the WriteSecured() method with the same item handle as was used for the Write() method. - -**Examples** - -[Visual Basic.NET] -```vbnet -Call LMX_Server.Write(hLMX, hItem, pItemValue, uindex1) -``` -[C#] -```csharp -LMX_Server.Write(hLMX, hItem, pItemValue, uindex1); -``` -[Visual C++] -```cpp -hr = pLMX_Server->Write(hLMX, hItem, pItemValue, uindex1); -``` - -## Write2() Method - -Writes data and a timestamp to an attribute. - -### Syntax - -[Visual Basic.NET] -```vbnet -Sub Write2( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByVal pItemValue As Object, - ByVal pItemTime As Object, - ByVal UserID As Integer -) -``` -[C#] -```csharp -void Write2( - int hLMXServerHandle, - int hItem, - object pItemValue, - object pItemTime, - int UserID -); -``` - -[Visual C++] - -```c -HRESULT __stdcall Write2( - long hLMXServerHandle, - long hItem, - VARIANT pItemValue, - VARIANT pItemTime, - long UserID -); -``` - -**Parameters** - -**hLMXServerHandle** -The handle of the connection. - -**hItem** -The handle of the item. - -**pItemValue** -The new data value. - -**pItemTime** -The new timestamp. - -**UserID** -The ID "cookie" for the user. If ArcestrA security is not enabled, this value is -1 or 0. - -**Returns** - -None. - -**Remarks** - -Before you can write to an attribute, you must add the item to the internal table and put it on advise. For more information, see "AddItem() Method" on page 26 and "Advise() Method" on page 33. - -Upon completion of the write, your program receives notification of the success/failure status through the OnWriteComplete() event. - -The Write2() method is on the ILMXProxyServer4 interface, which extends the ILMXProxyServer interface. - -If a secured write or verified write with a timestamp is required, call the WriteSecured2() method with the same item handle as was used for the Write2() method. - -**Examples** - -[Visual Basic.NET] - -```vb -Call LMX_Server.Write2(hLMX, hItem, pItemValue, pItemTime, uindex1) -``` - -[C#] - -```csharp -LMX_Server.Write2(hLMX, hItem, pItemValue, pItemTime, uindex1); -``` - -Writing Data Values [Visual C++] -```c++ -hr = pLMX_Server->Write2(hLMX, hItem, pItemValue, pItemTime, - uindex1); -``` - -# WriteSecured() Method - -Writes data to an attribute that requires authentication, either as a secured write or as a verified write. - -## Syntax - -[Visual Basic.NET] -```vb -Sub WriteSecured( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByVal CurrentUserID As Integer, - ByVal VerifiedUserID As Integer, - ByVal pItemValue As Object -) -``` -[C#] -```csharp -void WriteSecured( - int hLMXServerHandle, - int hItem, - int CurrentUserID, - int VerifiedUserID, - object pItemValue -); -``` -[Visual C++] -```c++ -HRESULT __stdcall WriteSecured( - long hLMXServerHandle, - long hItem, - long CurrentUserID, - long VerifiedUserID, - VARIANT pItemValue -); -``` - -## Parameters - -**hLMXServerHandle** -* Handle of the connection. - -**hItem** -* Handle of the item. - -**CurrentUserID** -* ID "cookie" for the current user. - -**VerifiedUserID** -ID "cookie" for the verified user, or -1 or 0 if only a secured write is being done. - -**pItemValue** -The new data. - -**Returns** -Nothing. - -**Remarks** -If the attribute requires a secured write or verified write, you write to it with the WriteSecured() method. A secured write is analogous to submitting a change request that requires one signature, while a verified write corresponds to a change request that requires two signatures. - -Each user must be authenticated by name and password, thereby obtaining the ID "cookie." For more information, see "AuthenticateUser() Method" on page 54. - -Before you can write to an attribute, you must add the item to the internal table and put it on advise. For more information, see "AddItem() Method" on page 26 and "Advise() Method" on page 33. - -Upon completion of the write, your program receives notification of the success/failure status through the OnWriteComplete() event. - -**Examples** - -[Visual Basic.NET] -```vb -Call LMX_Server.WriteSecured(hLMX, hItem, uindex1, uindex2, - pItemValue) -``` - -[C#] -```csharp -LMX_Server.WriteSecured(hLMX, hItem, uindex1, uindex2, - pItemValue); -``` - -[Visual C++] -```cpp -hr = pLMX_Server->WriteSecured(hLMX, hItem, uindex1, uindex2, - pItemValue); - -Writing Data Values # WriteSecured2() Method - -Writes data and timestamp to an attribute that requires authentication, either as a secured write or as a verified write. - -## Syntax - -**[Visual Basic.NET]** - -```vbnet -Sub WriteSecured2 ( - ByVal hLMXServerHandle As Integer, - ByVal hItem As Integer, - ByVal CurrentUserID As Integer, - ByVal VerifiedUserID As Integer, - ByVal pItemValue As Object, - ByVal pItemTime As Object -) -``` - -**[C#]** - -```csharp -void WriteSecured2 ( - int hLMXServerHandle, - int hItem, - int CurrentUserID, - int VerifiedUserID, - object pItemValue, - object pItemTime -); -``` - -**[Visual C++]** - -```cpp -HRESULT __stdcall WriteSecured2 ( - long hLMXServerHandle, - long hItem, - long CurrentUserID, - long VerifiedUserID, - VARIANT pItemValue, - VARIANT pItemTime -); -``` - -## Parameters - -**hLMXServerHandle** -Handle of the connection. - -**hItem** -Handle of the item. - -**CurrentUserID** -ID "cookie" for the current user. - -**VerifiedUserID** -ID "cookie" for the verified user, or -1 or 0 if only a secured write is being done. - -pItemValue -The new data. - -pItemTime -The new timestamp. - -**Returns** -Nothing. - -**Remarks** -If the attribute requires a secured write or verified write, you write to it with the WriteSecured2() method. A secured write is analogous to submitting a change request that requires one signature, while a verified write corresponds to a change request that requires two signatures. - -Each user must be authenticated by name and password, thereby obtaining the ID "cookie." For more information, see "AuthenticateUser() Method" on page 54. - -Before you can write to an attribute, you must add the item to the internal table and put it on advise. For more information, see "AddItem() Method" on page 26 and "Advise() Method" on page 33. - -Upon completion of the write, your program receives notification of the success/failure status through the OnWriteComplete() event. - -The WriteSecured2() method is on the ILMXProxyServer4 interface, which extends the ILMXProxyServer interface. - -**Examples** - -[Visual Basic.NET] -```vbnet -Call LMX_Server.WriteSecured2 (hLMX, hItem, uindex1, -uindex2, pItemValue, pItemTime) -``` - -[C#] -```csharp -LMX_Server.WriteSecured2 (hLMX, hItem, uindex1, -uindex2, pItemValue, pItemTime); -``` - -[Visual C++] -```cpp -hr = pLMX_Server->WriteSecured2 (hLMX, hItem, -uindex1, uindex2, pItemValue, pItemTime); -``` - -Writing Data Values # Writing to a Single Element - -When writing to a single ArchestrA element—that is, when you are not writing to several members of an array—it is possible to pass a string object (or VARIANT) to the Write() or WriteSecured() method: - -[Visual Basic.NET] -```vb -Dim vValue as String -Call LMX_Server.Write(hLMX, hItem, vValue, uindex1) -``` - -[C#] -```csharp -string vValue; -LMX_Server.Write(hLMX, hItem, vValue, uindex1); -``` - -[Visual C++] -```cpp -TCHAR sValue; - ... // set value of sValue to string - // representing the new value -VARIANT varItem; - if (ItemAsVariant (&varItem, sValue) { - hr = pLMX_Server->Write(hLMX, hItem, varItem, uindex1); - } -``` - -# Writing to an Array - -When writing to an array of ArchestrA elements, you must build an object (or in C++ a VARIANT) that contains the data as an array. It can be an array of strings or it can be an array of the actual data type. - -For more information on implementing the handling in the ItemArrayAsVariant() and ReleaseItemArrayAsVariant() methods, see "Encapsulating Data Values in Visual C++" on page 52. - -[Visual Basic.NET] -```vb -Dim vArray(2) as String -vArray(0) = vValue1.Text -vArray(1) = vValue2.Text -vArray(2) = vValue3.Text -Call LMX_Server.Write(hLMX, hItem, vArray, uindex1) -``` - -[C#] -```csharp -string[] vArray = { vValue1.Text, vValue2.Text, vValue3.Text }; -LMX_Server.Write(hLMX, hItem, vArray, uindex1); -``` - -[Visual C++] - -```c++ -#define INTARRAY_LEN 256 -typedef TCHAR INTARRAY_ELEM [INTARRAY_LEN] -INTARRAY_ELEM intarray[3]; - -... // set values of intarray[0], intarray[1], //intarray[2] to strings representing new values - -VARIANT varArray; - -// build SAFEARRAY and put it into varArray -// (see example of implementation below) - -if (ItemArrayAsVariant (&varArray, VT_BSTR, intarray, 3)) -{ - hr = pLMX_Server->Write(hLMX, hItem, varItem, uindex1); - ReleaseItemArrayAsVariant (&varItem, 3); -} -``` - -# Encapsulating Data Values in Visual C++ - -To encapsulate the new data values as a SAFEARRAY, the simplest approach is to let the VARIANT mechanism handle the data conversion to the appropriate data type. Note that there is a slight difference on how the data should be handled if strings (BSTRs) are being stored in the SAFEARRAY. You must copy the string pointer into the entry, instead of the actual value. - -The following example shows how to convert the new entries and put them into a SAFEARRAY. This same function is also included in the sample code for C++. - -```c++ -bool ItemArrayAsVariant (VARIANT* pvarItems, - VARTYPE datatype, - INTARRAY_ELEM* intarray, long NumEntries) - -{ - bool bRetVal = false; - long i; - HRESULT hr = S_OK; - VariantInit (pvarItems); - // set up a safe array to hold the data - SAFEARRAYBOUND rgsabound[1]; - rgsabound[0].lBound = 0; - rgsabound[0].cElements = NumEntries; - SAFEARRAY *psa = SafeArrayCreate (datatype, 1, rgsabound); - if (psa) - { - // create entries - VARIANT varEntry; -``` - -Encapsulating Data Values in Visual C++ ```c++ -for (i = 0; (i < NumEntries) && (SUCCEEDED(hr)); i++) { - VariantInit(&varEntry); - varEntry.vt = VT_BSTR; - varEntry.bstrVal = ::SysAllocString(intarray[i]); - switch (datatype) { - case VT_BSTR : { - SafeArrayPutElement(psa, &i, (void *)varEntry.bstrVal); - } - break; - default : { - hr = VariantChangeType(&varEntry, - &varEntry, 0, datatype); - // since VARIANT is a union, just - // point to where value is stored - SafeArrayPutElement(psa, &i, (void *)&varEntry.lVal); - } - break; - } // switch // - // free the temporary string - if (varEntry.vt == VT_BSTR) { - ::SysFreeString(varEntry.bstrVal); - } -} -// modify the flags in fFeatures -if (datatype == VT_BSTR) { - psa->fFeatures |= (unsigned short) FADF_BSTR; -} else { - psa->fFeatures = 0; -} -if (SUCCEEDED(hr)) { - // attach the array to the variant - pvarItems->parray = psa; - pvarItems->vt = (unsigned short) (datatype | VT_ARRAY); - // indicate successfully created - bRetVal = true; -} -return (bRetVal); -} -``` - -```c -void ReleaseItemArrayAsVariant (VARIANT* pvarItems, long NumEntries) -{ - UNREFERENCED_PARAMETER(NumEntries); - SAFEARRAY *psa = pvarItems->parray; - if (psa) { - SafeArrayDestroy(psa); - } -} -``` - -# Authenticating Users - -Some ArchestraA data is secured. The entire Galaxy can have security enabled and individual attributes can be secured in such a way that they can be updated only if the user has certain login credentials. Some attributes require a verified write, which means that two different users must confirm that the update should take place. This involves calling the AuthenticateUser() method, which asks the Galaxy to check a user's credentials and return an ID "cookie" that can be used in the Write() and WriteSecured() method calls. - -# AuthenticateUser() Method - -## Syntax - -**[Visual Basic.NET]** - -```vb -Function AuthenticateUser( - ByVal hLMXServerHandle As Integer, - ByVal VerifyUser As String, - ByVal VerifyUserPsw As String -) - As Integer -``` - -**[C#]** - -```csharp -int AuthenticateUser( - intLMXServerHandle, - string VerifyUser, - string VerifyUserPsw -); -``` - -**[Visual C++]** - -```cpp -HRESULT __stdcall AuthenticateUser( - long hLMXServerHandle, - BSTR VerifyUser, - BSTR VerifyUserPsw, - long *UserId -); -``` - -Authenticating Users **Parameters** - -* **hLMXServerHandle** - Handle of the connection. -* **VerifyUser** - User account name. -* **VerifyUserPsw** - User password. - -**Returns** - -ID "cookie" for the user, or zero if the user's credentials are rejected. - -**Remarks** - -The AuthenticateUser() method gives a name and password to the Galaxy for authorization. If the user name and password are valid, a GUID is generated for the log-in. However, instead of returning this GUID to the caller, the LMXProxyServer stores the GUID in its internal tables and assigns it an integer index—an ID "cookie"—that can be used in subsequent calls to other methods. If the user's credentials are invalid (wrong name, wrong password or both), the underlying COM function returns HRESULT 0x80070057 indicating that the parameters are invalid. In C#, this will throw a System.ArgumentException; and in VB it will throw an error which is identified as Err.Number 5. Your program should be prepared to check the HRESULT or handle the exception, and indicate that the user's credentials are invalid. - -**Examples** - -[Visual Basic.NET] -```vbnet -uindex1 = LMX_Server.AuthenticateUser(hLMX, uidText, pwdText) -``` -[C#] -```csharp -uindex1 = LMX_Server.AuthenticateUser(hLMX, uidText, pwdText); -``` -[Visual C++] -```cpp -BSTR bstrUser = ::SysAllocString(szUserId); -BSTR bstrPwd = ::SysAllocString(szUserPwd); -hr = pLMX_Server->AuthenticateUser(hLMX, bstrUser, bstrPwd, - &uindex1); -::SysFreeString(bstrPwd); -::SysFreeString(bstrUser); -``` - -# Using an Established User Authentication - -When your program calls the AuthenticateUser() method, LMX authenticates the user name and password and gets a GUID from the ArchestrA Galaxy that is valid for the current session. To simplify use in MXAccess, the LMXProxyServer then places this GUID in an internal table and associates it with an integer ID "cookie" so that the interfaces do not need to pass a GUID as a parameter every time an operation such as the Write() method or WriteSecured() is invoked. In short, the GUID is obtained, but your program never sees it. - -If your program is a component hosted in InTouch—for example, an ActiveX object hosted in an InTouch application window—the policy is for the InTouch HMI to handle the authentication process, using its own log-in dialog boxes. In particular, WindowViewer presents a log-in dialog box when: - -* An operator logs into the InTouch application. -* A secured write is to be performed. -* A verified write is to be performed. - -WindowViewer keeps track of the GUIDs for these users. For your program to use these GUIDs to perform secured writes and verified writes, you must add them to the internal tables for the LMXProxyServer. Do this by calling the ArchestrAUserId() method, which passes the GUID to the LMXProxyServer, checks it for validity, and returns the corresponding ID cookie. You can then use this cookie with the other interface methods, such as the Write() and WriteSecured() method. - -Note that this method is on the ILMXProxyServer2 interface, which extends the ILMXProxyServer interface. - -## ArchestrAUserId() Method - -Adds a GUID to the internal table and returns a user ID. - -### Syntax - -[Visual Basic.NET] - -```vbnet -Function ArchestrAUserId( - ByVal hLMXServerHandle As Integer, - ByVal UserIdGuid As String -) -As Integer -``` - -Using an Established User Authentication [C#] - -```csharp -int ArcestrAUserToId( - int hLMXServerHandle, - string UserIdGuid); -``` - -[Visual C++] - -```cpp -HRESULT __stdcall ArcestrAUserToId( - long hLMXServerHandle, - BSTR UserIdGuid, long *UserId -); -``` - -**Parameters** - -**hLMXServerHandle** -Handle of the connection. - -**UserIdGuid** -String containing the GUID for the user ID. - -**Returns** - -User ID cookie, or zero if the user credentials are rejected. - -**Remarks** - -This method is used primarily in the context of a component hosted by the InTouch HMI. The InTouch HMI is written mostly in unmanaged code and therefore does not generally support hosting .NET visual components in an InTouch application window. However, an ActiveX component can be hosted in an InTouch window. Code samples in this Toolkit include an example in C++, implementing a simple ActiveX component that uses this interface. - -Every time you call the ArcestrAUserToId() method with a valid GUID, the GUID is added to the internal table and a new ID is returned. If you add the same GUIDs over and over again, the table continues to grow and previous IDs returned for those same GUIDs can still be used to perform Write() and WriteSecured() operations. - -To minimize duplicate entries, your program can maintain a map of GUIDs already submitted to the ArcestrAUserToId() method and their corresponding ID cookies and submit a GUID only if it has not already been used. - -**Examples** - -[Visual Basic.NET] - -```vbnet -uindex1 = LMX_Server.ArcestrAUserToId(hLMX, uidGuid) -``` - -[C#] - -```csharp -uindex1 = LMX_Server.ArcestrAUserToId(hLMX, uidGuid); -``` - -[Visual C++] - -```c++ -BSTR bstrGuid = ::SysAllocString(szUserIdGuid); -hr = pLMX_Server->ArchestrAUserToId(hLMX, bstrGuid, &uindex1); -::SysFreeString(bstrGuid); -``` - -# Obtaining User Authentication from InTouch - -When a component hosted by the InTouch HMI requires a user log-in, as for a secured write or verified write, let the InTouch HMI handle the authentication using its own user log-in dialog boxes. There are two ways to get user GUIDs from the InTouch HMI: - -* Call the get_LoggedInUserGuid() method to retrieve the GUID for the user currently logged in, if any. -* Call the LogInInTouchUser() method to show a confirmation log-in from the currently logged-in user (and, if appropriate, a verification user). - -These are not methods of the MXAccess Toolkit, but are instead methods provided by the InTouch HMI itself, through the AppServerSecurity.dll. - -To access the AppServerSecurity methods, you must import a reference to the AppServerSecurity.dll into your program. - -[Visual C++] - -* In the file stdafx.h, add a line such as the following to import the dll: - -```c++ -#import "C:\Program Files\Wonderware\InTouch\AppServerSecurity.dll" -no_namespace, raw_interfaces_only -``` -* When you compile, Visual Studio creates a file AppServerSecurity.tlh. You can open this file to see the interface methods and other members provided by the AppServerSecurity interface. - -In addition to the methods described above, the DLL includes the following enumerations for the LogInInTouchUser() method: - -```c++ -enum WriteType -{ - WriteType_Undefined = 0, - WriteType_VerifiedWrite = 1, - WriteType_SecuredWrite = 2 -}; -``` - -```c++ -enum LoginDetailError { - LoginDetailError_NoError = 0, - LoginDetailError_SecuredLoginFailed = 1, - LoginDetailError_VerifierLoginFailed = 2 -}; - -To retrieve the GUID of the currently logged-in user, call the get_LoggedInUserGuid() method. - -[Visual C++] - -## Syntax - -```c++ -HRESULT get_LoggedInUserGuid (BSTR * loggedInUserId); -``` - -## Example - -```c++ -// get GUID from InTouch for user -// currently logged in, if any -CComPtr pInTouchLogin; -HRESULT hr = pInTouchLogin.CoCreateInstance( - _uuidof( CurrentInTouchSecurity )); -if (SUCCEEDED(hr)) -{ - CComBSTR bstrGUID; - hr = pInTouchLogin->get_LoggedInUserGuid(&bstrGUID); - if (SUCCEEDED(hr)) - { - // add GUID to internal table - if ((m_pPLMX_Server != NULL) && (m_hLMX != 0)) - { - long tempIndex = 0; - hr = pLMX_Server->ArchestrAUserToId(m_hLMX, - bstrGUID, &tempIndex); - if (SUCCEEDED(hr) && (tempIndex != 0)) - { - // use tempIndex as new user index - ... - } - } - } -} -``` - -To invoke an InTouch log-in dialog and get GUIDs for the currently logged-in user (if any) and a verification user (if appropriate), call the LogInInTouchUser() method. - -[Visual C++] - -## Syntax - -```c++ -HRESULT LogInInTouchUser ( enum WriteType typeOfWrite, - BSTR * userId, - BSTR * verifierId, - VARIANT_BOOL * success, - enum LoginDetailError * detailCode); -``` - -## Example - -```c++ -// get GUID(s) from InTouch by invoking -// log-in dialog -CComPtr pInTouchLogin; -HRESULT hr = pInTouchLogin.CoCreateInstance( - _uuidof( CurrentInTouchSecurity )); -if (SUCCEEDED(hr)) -{ - WriteType typeOfWrite = - WriteType_VerifiedWrite; - CComBSTR bstrUserID; - CComBSTR bstrVerifierID; - VARIANT_BOOL varbSuccess = VARIANT_FALSE; - LoginDetailError detailCode = - LoginDetailError_NoError; - // determine type of login required - if (bNeedVerifiedWrite) - typeOfWrite = WriteType_VerifiedWrite; - else - typeOfWrite = WriteType_SecuredWrite; - // Ask InTouch to get user login(s) and - // return GUIDs - hr = pInTouchLogin->LogInInTouchUser(typeOfWrite, - &bstrUserID, - &bstrVerifierID, &varbSuccess, &detailCode); - if (SUCCEEDED(hr)) - { - // get status information - bool bSuccess = - (varbSuccess != VARIANT_FALSE); - if (bSuccess) - { - // GUID(s) obtained - // Add them to internal tables, - // get corresponding ID(s) - if ((pLMX_Server != NULL) && (hLMX != 0)) -``` - -```c++ -{ - long tempIndex1 = 0; - long tempIndex2 = 0; - hr = - m_pLMX_Server->ArchestrAUserToId(m_hLMX, - bstrUserID, &tempIndex1); - if (SUCCEEDED(hr) && - (typeOfWrite == WriteType_VerifiedWrite)) - { - hr = - m_pLMX_Server->ArchestrAUserToId(m_hLMX, - bstrVerifierID, &tempIndex2); - } - // now use tempIndex1, - // tempIndex2 to update the IDs - if (tempIndex1 != 0) - // secured user - uindex1 = tempIndex1; - if (tempIndex2 != 0) - // verifier user - uindex2 = tempIndex2; - } -} -else -{ - // handle error or indicate problem, - // using returned detailCode for - // further information - ... -} -} -} - -The new user ID "cookies" may now be used in the WriteSecured() -method. -``` - -# Handling the OnDataChange Callback - -The LMXProxyInterface triggers an OnDataChange event when an update occurs to an ArcestrA Attribute that your program has on advise. The information your program receives contains the updated data values and status information. If appropriate, it may also provide error code information. - -## OnDataChange Event - -Reports the value and the status of an ArcestrA Attribute to which the program has subscribed. - -### Syntax - -**[Visual Basic.NET]** - -```vbnet -Private Sub LMX_OnDataChange( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Integer, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArcestrA.MxAccess.MXSTATUS_PROXY -) -``` - -```vbnet -Handles LMX_Server.OnDataChange -``` - -**[C#]** - -```csharp -private void LMX_OnDataChange ( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref ArcestrA.MxAccess.MXSTATUS_PROXY[] ItemStatus -); -``` - -**[Visual C++]** - -```cpp -HRESULT CLMX_Events_Shim::OnDataChange ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - VARIANT pvItemValue, - long pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAItemStatus -); -``` - -Handling the OnBufferedDataChange Callback **Parameters** - -* **hLMXServerHandle** - * Handle of the connection -* **phItemHandle** - * Handle of the item -* **pvItemValue** - * New data value -* **pwItemQuality** - * New quality -* **pftItemTimeStamp** - * New timestamp -* **ItemStatus** - * Array of Message Exchange statuses - -**Returns** - -Nothing. - -**Remarks** - -For more information, including the function syntax for the different languages, see "Connecting Handlers for Events Reported from the LMXProxyServer" on page 19. - -# Handling the OnBufferedDataChange Callback - -You can configure your client program to subscribe to buffered attributes. Your client program can also be configured to receive data change events for those attributes. - -When LMXProxy passes buffered data back to the client application, it builds three parallel SafeArray Variants, one each for: - -* Value (V) -* Timestamp (T) -* Quality (Q) - -# OnBufferDataChange Event - -Reports the VTQ buffer and the status of an ArchestrA Attribute to which the program has subscribed. - -## Syntax - -**[Visual Basic.NET]** - -```vb -Sub LMX_OnBufferDataChange( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal dtDataType As ArchestrA.MxAccess.MxDataType, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Object, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OnBufferDataChange -``` - -**[C#]** - -```csharp -LMX_OnBufferDataChange( - int hLMXServerHandle, - int phItemHandle, - ArchestrA.MxAccess.MxDataType dtDataType, - object pvItemValue, object pvItemQuality, - object pvItemTimeStamp, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[] ItemStatus) -``` - -**[Visual C++]** - -```cpp -HRESULT OnBufferDataChange ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - MxDataType dtDataType, - VARIANT pvItemValue, - VARIANT pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAItemStatus) -``` - -## Parameters - -**hLMXServerHandle** -Handle of the connection. - -**phItemHandle** -Handle of the item. - -**dtDataType** -Data type of the buffered data. - -**pvItemValue** -New data value. - -Determining Data Change Status * *pwItemQuality* - New quality. -* *pftItemTimeStamp* - New timestamp. -* *ItemStatus* - Array of Message Exchange statuses. - -**Returns** - -Nothing. - -**Remarks** - -You must note that when your client program attempts a buffered subscription to an attribute that does not have buffering enabled, a data change event occurs. This data change event message will have an MXStatus category of **MXCategoryConfigurationError** and will have a detail code of **MX_E_InvalidPropertyId**. - -For more information, including the function syntax for the different languages, see "Connecting Handlers for Events Reported from the LMXProxyServer" on page 19. - -# Determining Data Change Status - -If the data change notification indicates success, then you should process the received data. Otherwise, you should notify the user or log the error, as is appropriate for your application. - -[Visual Basic.NET] - -```vbnet -Dim sErrorStatus As String - -If ItemStatus(0).success Then - ' process data, update displays, etc. -Else - sErrorStatus = "Data Change Error - C:" & _ - ItemStatus(0).category & " S:" & ItemStatus(0).detectedBy & _ - " D:" & ItemStatus(0).detail -End If -``` - -[C#] - -```csharp -if (ItemStatus[0].success != 0) { - // process data, update displays, etc. -} else { - string sErrorStatus; - sErrorStatus = "Data Change Error - C:" + - ItemStatus[0].category + " S:" + ItemStatus[0].detectedBy + - " D:" + ItemStatus[0].detail; -} -``` - -[Visual C++] - -MXSTATUS_PROXY *ItemStatus; - -hr = SafeArrayAccessData(pSAItemStatus, (void HUGEP* FAR*)&ItemStatus); - -if (ItemStatus[0].success != 0) { - // process data, update displays, etc. -} else { - TCHAR sErrorStatus [256]; - swprintf_s (sErrorStatus, - sizeof(sErrorStatus)/sizeof(TCHAR), L"OnDataChange w/error - cat: %d Src: %d detail: %d", ItemStatus[0].category, ItemStatus[0].detectedBy, ItemStatus[0].detail); -} - -hr = SafeArrayUnaccessData(pSAItemStatus); - -# Displaying Data Quality and Time - -The new data value has an associated Quality, which is an integer, and represents whether the data is good, bad, pending, and initializing. The new data value also has an associated timestamp, which represents when the data was updated. These properties can be easily displayed in .NET. For displaying the Time in Visual C++, refer to the section below on Retrieving Data Types in Visual C++. - -## Syntax - -[Visual Basic.NET] - -```vbnet -Private Sub LMX_OnDataChange( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByVal pvItemValue As Object, - ByVal pwItemQuality As Integer, - ByVal pftItemTimeStamp As Object, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) -Handles LMX_Server.OnDataChange -``` - -## Example - -```vbnet -Quality.Text = pwItemQuality -Time.Text = pftItemTimeStamp -``` - -Displaying Data Quality and Time [C#] - -```csharp -private void LMX_OnDataChange( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` - -**Example** - -```csharp -Quality.Text = pwItemQuality.ToString(); -Time.Text = pftItemTimeStamp.ToString(); -``` - -[Visual C++] - -```cpp -HRESULT CLMX_Events_Shim::OnDataChange ( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - VARIANT pvItemValue, - long pwItemQuality, - VARIANT pftItemTimeStamp, - SAFEARRAY *pSAIItemStatus -); -``` - -**Example** - -```cpp -swprintf_s (strDisplayedText, TotalLen, L"%d", pwItemQuality); -SetDlgItemText (hMainDlg, IDC_Quality, strDisplayedText); -Variant_to_String (&pftItemTimeStamp, strDisplayedText, -sizeof(strDisplayedText)/sizeof(TCHAR)); -SetDlgItemText (hMainDlg, IDC_Time, strDisplayedText); -``` - -[ActiveX] - -**Example** - -```cpp -swprintf_s (strDisplayedText, TotalLen, L"%d", pwItemQuality); -m_pMxAccessAxDlg->SetDlgItemText(IDC_Quality, -strDisplayedText); -Variant_to_String (&pftItemTimeStamp, strDisplayedText, -sizeof(strDisplayedText)/sizeof(TCHAR)); -m_pMxAccessAxDlg->SetDlgItemText(IDC_Time, strDisplayedText); -``` - -# Determining Data Type - -Because the new data value is encapsulated as an object (or in Visual C++ as a VARIANT), it is necessary to cast the new data value to the appropriate data type to process it. If the data is an array of values, you must un-package the object and treat it as an array of the appropriate type. This is easily handled in .NET, but requires more effort in Visual C++. - -[Visual Basic.NET] - -```vbnet -Dim objectType As Type -Dim count As Integer - -' check whether data is array or single item -objectType = pvItemValue.GetType() - -If Not objectType.IsArray Then - ' received data is a single entry, handle it - ' do something with pvItemValue - ... -Else - ' received data is an array, - ' handle all the values - For count = 0 To UBound(pvItemValue, 1) Step 1 - ' do something with pvItemValue(count) - ... - Next count -End If -``` - -[C#] - -```csharp -Type objectType; -int count; - -objectType = pvItemValue.GetType(); - -if (!objectType.IsArray) { - // received data is a single entry, handle it - // do something with pvItemValue - ... -} -``` - -Determining Data Type ```csharp -} else { - // received data is an array, re-cast data type - // and handle all the values - Array pvArray = (Array) pvItemValue; - for (count = pvArray.GetLowerBound(0); - count < pvArray.GetUpperBound(0); - count++) { - // do something with pvArray[count] - ... - } -} -``` - -[Visual C++] - -```c -VARTYPE Variant_Type; - -long count; - -Variant_Type = pvItemValue.vt; - -if ((Variant_Type & VT_ARRAY) == 0) { - // received data is a single entry, handle it - // do something with pvItemValue - ... -} else { - // received data is an array, - // re-cast data type and handle all the values - Variant_Type = (VARTYPE) (Variant_Type ^ VT_ARRAY); - SAFEARRAY *pvArray = pvItemValue.parray; - SAFEARRAYBOUND* pSABound = pvArray->rgsabound; - long LowerBound = pSABound->lbound; - long UpperBound = pSABound->cElements + LowerBound; - for (count = LowerBound; count < UpperBound; count++) { - // do something with pvArray at index count - // see example implementation below - Process_SafeArrayElement (pvArray, Variant_Type, count, - ...); - } -} -``` - -For more information about implementing handling in the `Process_SafeArrayElement()` function, see "Retrieving Data Types in Visual C++" on page 70. - -When you have the new data value in the appropriate form, you can use it and the array of Message Exchange statuses to update the values that are tracked by your program. What your program does with status information up to you—whether to update status displays, perform error handling, and so on. - -# Retrieving Data Types in Visual C++ - -The most reliable way to retrieve data from a SAFEARRAY of values is to use the SafeArrayGetElement() method. However, this method call must be made with a data variable of the appropriate type, so it can be processed correctly. For an array of BSTR, it is slightly more efficient to access the entire array and reference the individual strings by index. - -There are several ways to handle the DATE data type. The following are two simple approaches to coerce the value to a string: - -* Use the VariantChangeType() function to convert the DATE to a BSTR. -* Use the VariantTimeToDosDateTime() function to convert the DATE to a DOS date and a DOS time, which can then be formatted into a string. - -The following code example shows how to extract entries from the SAFEARRAY. This function is included in the sample code for C++. - -```c++ -void SafeArrayElement_to_String(SAFEARRAY *pvArray, VARTYPE datatype, long count, LPTSTR szValue, size_t BufLen) -{ - switch (datatype) - { - case VT_BOOL : - { - VARIANT varBoolItem; - VariantInit(&varBoolItem); - SafeArrayGetElement(pvArray, &count, &varBoolItem); - swprintf_s(szValue, BufLen, L"%s", - (varBoolItem.boolVal ? L"true" : L"false")); - } - break; - case VT_I4 : - { - long iLongElement = 0; - SafeArrayGetElement(pvArray, &count, &iLongElement); - swprintf_s(szValue, BufLen, L"%ld", iLongElement); - } - break; - case VT_R8 : - { - double iDoubleElement = 0.0; - SafeArrayGetElement(pvArray, &count, - &iDoubleElement); - swprintf_s(szValue, BufLen, L"%f", iDoubleElement); - } - break; - } -} -``` - -Retrieving Data Types in Visual C++ ```c++ -case VT_R4 : -{ - float iFloatElement = 0.0; - SafeArrayGetElement (pvArray, &count, &iFloatElement); - swprintf_s (szValue, BufLen, L"%f", iFloatElement); -} -break; -case VT_DATE : -{ - DATE dtElement; - SafeArrayGetElement (pvArray, &count, &dtElement); - if (bDisplayDateUsingVariant) - { - // One way to display is to let - // the VARIANT mechanism handle it - VARIANT varDate; - VariantInit (&varDate); - varDate.vt = VT_DATE; - varDate.date = dtElement; - HRESULT hr = VariantChangeType (&varDate, &varDate, - 0, VT_BSTR); - if (SUCCEEDED(hr)) - { - wscpy_s (szValue, BufLen, varDate.bstrVal); - ::SysFreeString(varDate.bstrVal); - } else { - wcscpy_s (szValue, BufLen, L"????"); - } - } - else - { - // Another way is to extract the - // date/time fields explicitly - // note that a DOS time is only - // accurate to a resolution of - // 2 seconds - unsigned short wDosDate; - unsigned short wDosTime; - if (VariantTimeToDosDateTime (dtElement, &wDosDate, - &wDosTime)) - { - int da = wDosDate & 0x001F; - int mo = (wDosDate & 0x01E0) >> 5; - int yr = ((wDosDate & 0xFE00) >> 9) + 1980; - int sc = (wDosTime & 0x001F) << 1; - int mn = (wDosTime & 0x07E0) >> 5; - int hr = (wDosTime & 0xF800) >> 11; - swprintf_s (szValue, BufLen, - L"%02d/%02d/%04d %02d:%02d", - mo, da, yr, hr, mn, sc); - } - } -``` - -```c -else -{ - wcscpy_s (szValue, BufLen, L"????"); -} -} -break; -case VT_BSTR : -{ - BSTR HUGE *pbstr; - HRESULT hr = SafeArrayAccessData(pvArray, (void -HUGE**) &pbstr); - swprintf_s (szValue, BufLen, L"%s", pbstr[count]); - hr = SafeArrayUnaccessData(pvArray); -} -break; -default : -{ - wcscpy_s (szValue, BufLen, L"????"); -} -break; -} // switch // -} -``` - -# Handling the OnWriteComplete Callback - -The LMXProxyInterface triggers an event for OnWriteComplete when your program calls the Write() or WriteSecured() function. The information your program receives identifies whether the write was successful and any error code information that may be appropriate. - -## OnWriteComplete Event - -Reports the status when a write operation has been completed, indicating such information as whether it succeeded or failed. - -### Syntax - -[Visual Basic.NET] - -```vb -Private Sub LMX_OnWriteComplete( - ByVal hLMXServerHandle As Integer, - ByVal phItemHandle As Integer, - ByRef ItemStatus() As ArchestrA.MxAccess.MXSTATUS_PROXY -) - Handles LMX_Server.OnWriteComplete -``` - -Handling the OnWriteComplete Callback [C#] - -```csharp -private void LMX_OnWriteComplete( - int hLMXServerHandle, - int phItemHandle, - ref ArchestrA.MxAccess.MXSTATUS_PROXY[ ] ItemStatus -); -``` - -[Visual C++] - -```cpp -HRESULT CLMX_Events_Shim::OnWriteComplete( - long hLMXServerHandle, - ITEMHANDLE phItemHandle, - SAFEARRAY *pSAWriteItemStatus -); -``` - -where CLMX_Events_Shim is a class derived from the base class LMXProxyServerEvents. - -## Parameters - -**hLMXServerHandle** -Handle of the connection. - -**phItemHandle** -Handle of the item. - -**ItemStatus** -Array of Message Exchange statuses. - -## Returns - -Nothing - -## Remarks - -For more information, including the function syntax for the different languages, see "Connecting Handlers for Events Reported from the LMXProxyServer" on page 19. - -Interpreting the write status is very much like interpreting the data change status and involves examining the contents of the MxStatus structure. - -* If the success member of the status is true, the write operation was accepted, but it may not be complete. Check the category member of the status to determine whether the write is still pending. -* If the success member is false, check the other structure members for additional information about why the write operation failed. It could be that the operation requires a secured or verified write, instead of an "ordinary" write. - -You can check the Message Exchange statuses to determine whether the write was successful and to examine any error codes that have been returned. What your program does with the status information up to you—whether to update status displays, perform error handling, and so on. - -Any attempt to write to a buffered item will result in a OnWriteComplete status event returning an MxStatus with an MxCategoryOperationalError category and an detail code of MX_E_NotWriteable. - -## Examples - -[Visual Basic.NET] - -```vbnet -If ItemStatus(0).success Then - If ItemStatus(0).category = ArchestrA.MxAccess.MxStatusCategory.MxCategoryPending Then - WriteStatus.Text = "Write Pending..." - Else - WriteStatus.Text = "Write Complete - status OK" - End If -Else - If ItemStatus(0).detail = MX_E_SecuredWrite Then - ' 1012, secured write - ' either re-try as a secured write - ' or generate an error message - ... - Else - If ItemStatus(0).detail = MX_E_VerifiedWrite Then - ' 1013, verified write - ' either re-try as a verified write - ' or show an error message - ... - Else - ' some other kind of error, - ' display information - WriteStatus.Text = "Write Complete w/error - cat: " & - ItemStatus(0).category & " Src: " & - ItemStatus(0).detectedBy & " detail: " & - ItemStatus(0).detail - End If - End If -End If -``` - -Handling the OnWriteComplete Callback [C#] - -```csharp -if (ItemStatus[0].success != 0) { - if (ItemStatus[0].category == ArchestrA.MxAccess.MxStatusCategory.MxCatgeoryPending) { - WriteStatus.Text = "Write Pending..."; - } else { - WriteStatus.Text = "Write Complete - status OK"; - } -} else { - if (ItemStatus[0].detail == MX_E_SecuredWrite) { - // 1012, secured write - // either re-try as a secured write - // or generate an error message - ... - } else { - if (ItemStatus[0].detail == MX_E_VerifiedWrite_val) { - // 1013, verified write - // either re-try as a verified write - // or show an error message - } else { - // some other kind of error, - // display information - PokeValue.Text = - "Write Complete w/error - cat: " + - ItemStatus[0].category + " Src: " + - ItemStatus[0].detectedBy + - " detail: " + ItemStatus[0].detail; - } - } -} -``` - -[Visual C++] - -```cpp -if (ItemStatus[0].success != 0) { - if (ItemStatus[0].category == MxCatgeoryPending) { - SetDlgItemText(hMainDlg, IDC_PokeValue, - L"Write Pending..."); - } else { - SetDlgItemText(hMainDlg, IDC_PokeValue, - L"Write Complete - status OK"); - } -} - -```c -} else { - if (ItemStatus[0].detail == MX_E_SecuredWrite) { - // 1012, secured write - // either re-try as a secured write - // or generate an error message - ... - } else { - if (ItemStatus[0].detail == MX_E_VerifiedWrite) { - // 1013, verified write - // either re-try as a verified write - // or show an error message - ... - } else { - // some other kind of error, - // display information - TCHAR error_text[256]; - swprintf_s (error_text, - sizeof(error_text)/sizeof(TCHAR), - L"Write Complete w/error - cat: " - L"%d Src: %d" "detail: %d", - ItemStatus[0].category, ItemStatus[0].detectedBy, - ItemStatus[0].detail); - SetDlgItemText (hMainDlg, IDC_WriteStatus, - error_text); - } - } -} -``` - -# Setting Update Intervals for Buffered Attributes - -LMXProxy will also allow your client program to configure the rate at which buffered data change updates are received. If you do not set this rate, the buffered data update events are sent to the client program once per second. - -## Syntax - -**[Visual Basic.NET]** - -```vb -Sub SetBufferedUpdateInterval ( - ByVal hLMXServerHandle As Integer, - ByVal lUpdateInterval As Integer) -``` - -**[C#]** - -```csharp -void SetBufferedUpdateInterval ( - int hLMXServerHandle, - int lUpdateInterval) -``` - -Setting Update Intervals for Buffered Attributes [Visual C++] - -```c++ -HRESULT __stdcall SetBufferedUpdateInterval( - long hLMXServerHandle, - long lUpdateInterval) -``` - -**Parameters** - -* **hLMXServerHandle** - * Handle of the connection - -* **lUpdateInterval** - * Specifies the update interval in milliseconds - -**Examples** - -[Visual Basic.NET] - -```vb -LMX_Server.SetBufferedUpdateInterval(hLMX, Interval) -``` - -[C#] - -```csharp -LMX_Server.SetBufferedUpdateInterval(hLMX, Interval); -``` - -[Visual C++] - -```c++ -m_pServerCMInterface->SetBufferedUpdateInterval(hLMXServerHandle, 3000); -``` - -**Remarks** - -If you set a **Negative** or **Zero (0)** value, it will return an error with value E-INVALIDARG HResult. - -If you set a **Positive** value, the value will be rounded to the next modulo 100 value. For example, a value of 1-100 will be 100, and 101 will be set to 200 and so on. - -# Chapter 3: Using Code Samples - -Code samples support Microsoft Visual Studio 2012 or later. Code samples run on Application Server 2014 or later version nodes that have a Platform deployed. For information about running the samples on a node with an earlier version of Application Server (for example, 2.1 Patch 02), see "Registering the Interface Files" on page 10. - -To build the sample projects VcppMxAccessActiveX and VcppMxSample on a 64-bit operating system, you must modify the import statement to include (x86) in the file path. - -**To build the sample projects on a 64-bit operating system** - -1. Modify the import statement in stdafx.h.as shown in the following example: - -```c -#import "C:\Program Files (x86)\ArchestrA\Framework\Bin\MxAccess32.tlb" no_namespace, raw_interfaces_only -``` - -2. Modify the import path in stdafx.h to match the InTouch installation directory as shown in the following example: - -```c -#import "C:\Program Files (x86)\Wonderware\InTouch\AppServerSecurity.dll" no_namespace, raw_interfaces_only -``` - -# To implement the basic program - -The examples for a stand-alone program are in Visual Basic, C#, and Visual C++. All three examples implement the basic program. - -MxAccess C# Example window showing various buttons and text fields for application registration, item functions, item value, poke item, security, operation status, communication management functions, and buffered data interval management. - -The code examples show how to connect to Message Exchange, access ArchestrA data, and shut down. The text fields and buttons allow you to select an attribute, add a reference for it, put it on advise, receive data updates, and write to the attribute. It also supports logging on under one or two names, to demonstrate secured writes and verified writes. - -For information about setting up a simple ArchestrA Galaxy configuration for running the sample application, see "Setting Up a Simple Galaxy Configuration for the Sample Applications" on page 86. - -This is a very simple demonstration program and does not keep track of adding and advising multiple items, even though the LMXProxy is capable of doing so. If you enter a new item name and add or advise that item, the program unadvises and removes the present item, if any. - -The sample program has a few safeguards in place to demonstrate the possibility of including such safeguards in your own code: - -* If you click **Add Item** before clicking **Register**, the program registers for you. -* If you click **Advise** before clicking **Add Item**, the program adds the item and registers it, if necessary. -* If you click **Remove Item**, the program checks whether there are any items on advise. If so, it unadvises them for you. -* If you click **Unregister**, the program checks whether there are any items on advise. If so, it unadvises them for you. It then checks whether there are any items in the reference table. If so, it removes those items for you and then unregisters. - -**To access data in an ArchestrA Galaxy** - -1. Start the program. -2. In the **Application Level Functions** area, click **Register** to connect to LMX Message Exchange. -3. In the **Item Functions** area, do the following: - * a Type the item name. - * b Click **AddItem** to add the item to the reference tables. - * c If you wish use AdviseSupervisory, check the box marked **Supervisory Connection**. If you wish to use Advise, clear the box. - * d Click **Advise** to put the item on advise. Updates for the item appear. If item is not on advise, you do not receive data updates. - -4 In the **Poke into item** area, do the following: -a Type the value in the first text box. -b If you are poking to only a single-valued attribute, leave the remaining text boxes blank. If you are poking to an array, type values for the array in the two remaining text boxes. -c If you wish to write the timestamp as well as the value, type the desired timestamp in the **Time** text box. Otherwise, leave the **Time** text box blank. -d Click **Poke** to write the value. - -Note: Success or failure is shown in the first text box. If the attribute requires a secured or verified write, the program attempts it automatically. It succeeds only if you have logged on under one or more account names. - -5 In the **Security** area, do the following: -a Type the name and password for **User1** and **User2**. If you are logging on to one account, use the **User1** and **Pwd1** text boxes. If you are logging on to two accounts, use **User2** and **Pwd2** text boxes. -b Click **Register UserIds** to log on. After logging on, the program succeeds in secured writes and verified writes, if the accounts have the appropriate credentials for the selected item. - -6 In the **Communication Management Functions** area, do the following: -a Click **Suspend** to pause data updates. -b Click **Activate** to resume updates. - -7 In the **Items Function** area, do the following: -a Click **Unadvise** to take the item off advise. You no longer receive data updates. -b Click **RemoveItem** to remove the item from the reference tables. - -8 In the **Applications Level Functions** area, click **Unregister** to disconnect from LMX Message Exchange. - -9 Shut down the program. - -Using an ActiveX Code Sample # Using an ActiveX Code Sample - -This example of ActiveX code demonstrates the ArchestrAUserToId() method. Use this method in applications, such as an InTouch application, where the user log on is done outside your component. The program is similar to the other code samples, although there are some minor differences. - -MxAccess ActiveX Sample window with various functions and fields including Application Level Functions (Register, Unregister), Item Functions (Advise, UserDefined_001.Attr1, Unadvise), Item (Value 1000, Quality 192, Time 8/6/2008 4:28:59.696 PM, Status), Poke into item (Value 1000, Time 8/6/2008 4:28:59.696 PM, Status Write Complete - status OK), Communication Management Functions (Suspend, Activate, Status Operation Complete - status OK), GUID of Logged-in User (Get GUID, ID {84F0B924-B158-4777-BEFD-A4BE10CC774A}), Security (Secured Write, Verified Write, Log In), IDs (IDs 1, 2, 3), Status SUCCESS, Detail OK. - -* The edit fields for PokeValue1 and PokeValue2 are omitted to leave more room for other buttons and display fields. Although the control shows array attributes, it does not support writing an array of new values. See the other code examples for an implementation of writing an array. -* Buttons and edit fields have been added for retrieving and displaying the GUIDs and corresponding ID "cookies". - -The ActiveX control must be registered on your computer and then registered with WindowMaker. Create a simple InTouch application with a single window and place an instance of the ActiveX control on the window. Configure the application for ArchestrA security. Configure WindowViewer so the application window is the start-up window for the application. - -**To access data in an ArchestrA Galaxy using ActiveX** - -1. Start WindowViewer. -2. In the **Application Level Functions** area, click **Register** to connect to LMX Message Exchange. -3. In the **Item Functions** area, do the following: - a. Type the item name. - b. Click **Advise** to put the item on advise. Updates for the item appear. -4. In the **Poke into item** area, do the following: - a. Type the value in the **Value** text box. - b. If you wish to write the timestamp as well as the value, type the desired timestamp in the **Time** text box. Otherwise, leave the **Time** text box blank. - c. Click **Poke** to write the value. Results appear in the **Status** box. - If the Galaxy is secured, and you have not obtained the ID for the logged on user, the write should fail. If the attribute requires a secured write or a verified write, and the program has not yet obtained the required IDs (of the logged on user and of a verified user, if needed), then the write fails. -5. In the **GUID of Logged in User** area, click **Get GUID**. - If the user has not logged on, zeros appear for the GUID. The ID "cookie" should also be zero. In the InTouch application, click **Log-in** on the **Security** menu to log on, and then click **Get GUID**. The display shows the GUID of the logged on user and a non-zero ID "cookie." -6. In the **Poke into item** area, click **Poke**. Attributes that require only an ordinary write, or a secured write succeed. -7. In the **Item Functions** area, click **Unadvise** and edit the item name to select an attribute that requires a secured write or a verified write. Then, click **Advise** to put the new item on advise. - -Using an ActiveX Code Sample **8** In the **Security** area, click **Log In** to show an InTouch log on dialog box. - -* If **Secured Write** is selected, a confirmation dialog box just for the logged on user is shown. If **Verified Write** is selected, a dialog box is shown that prompts the logged on user to confirm the password and asks the verifying user to enter a name and password. -* The display should show the GUID of the user(s) and corresponding ID "cookies," along with the status and detail information about the log on. -* If the user password is incorrect, the function indicates it failed to obtain a GUID. -* If **Verified Write** is selected and the logged on user password is incorrect, the function indicates it failed to obtain the GUIDs, even if the verifying user logon is correct. -* If **Verified Write** is selected and the verifying user logon is incorrect, the function indicates it failed to obtain the GUIDs, even if the logged-in user's password is correct. -* If the proper GUIDs have been obtained, secured and verified writes should now work. - -**9** In the **Communication Management Functions** area, do the following: - a Click **Suspend** to pause data updates. - b Click **Activate** to resume updates. - -**10** In the **Items Function** area, click **Unadvise** to take the item off advise. You no longer receive data updates. - -**11** In the **Applications Level Functions** area, click **Unregister** to disconnect from LMX Message Exchange. - -This sample program has the same safeguards in place as the other examples to ensure that the Register operation is invoked before attempting any other operations and to ensure that a proper shut down takes place, even if the user does not click **Unregister** before terminating WindowViewer. - -# Setting Up a Simple Galaxy Configuration for the Sample Applications - -You must have an ArchestrA Galaxy and a MXAccess runtime license to run the sample applications. For most operations you can use a Galaxy with no security. You can register, add items, put them on advise, see the updates, and write to them. - -If you already have a Galaxy set up, you may want to simply run the program and enter the name of an attribute of some object that has been created and deployed. - -## Setting up Attributes - -You can establish a simple set of attributes that can be referenced in the sample application dialog box and will update automatically. If no security is enabled, you can simply start up the sample application and even write to the selected attributes. - -**Note:** To advise an entire array, the reference should be of the form "ud1.ar[ ]". - -### To set up a simple set of attributes - -1. Start the ArchestrA IDE. -2. In the **Deployment** window, do the following: - a. Create an instance of a WinPlatform. - b. Create an AppEngine and assign it to the WinPlatform. - c. Create an Area and assign it to the AppEngine. - d. Create an instance of $UserDefined, give it the name "ud1", and assign it to the Area. - e. Double click ud1 to start the editor. -3. On the **UDAs** tab, add the following attributes and make them all user writable: - * x, of data type Integer - * ar, of data type Integer, an array of 3 elements - * dt, of data type Time - * ardt, of data type Time, an array of 3 elements - -Setting Up a Simple Galaxy Configuration for the Sample Applications 4 On the **Scripts** tab, create the following script s1: - Execution type: execute - Expression: 1 - Trigger type: While true - Trigger period and Deadband 0 - Statements - * me.x = me.x + 1; - * me.ar[1] = me.ar[1] + 1; - * me.ar[2] = me.ar[2] + 2; - * me.ar[3] = me.ar[3] + 3; - -5 Save and close the editor. - -6 Deploy the WinPlatform and all objects underneath. - -# Setting up a Simple Security Configuration - -To exercise secured writes and verified writes, you must configure the Galaxy for security and have at least two users that can be configured to log in. - -## To set up a simple security configuration - -1 Start ArchestrA IDE, and select **Galaxy**, and then **Configure** and **Security**. The **Configure Security** dialog box appears. - -2 Click the **Authentication Mode** tab and review the settings. -If the current security mode is "none" you must enable security. For the purposes of this exercise, select Galaxy security. If the security mode changes, you must shut down the Galaxy, open the IDE again and log on. A recommended log on in this case is as Administrator. See the ArchestrA documentation for more information. - -3 Click the **Roles** tab, and then do the following: - a Select the **Default** role, and then clear all the permissions. - b Create the **Writers** role. Check all the **General** permissions and all the **Operational** permissions. - c Create the **NonWriters** role. Check all the **General** permissions and all the **Operational** permissions, except for the **Can modify Operate attributes**. Leave that box unchecked. - -4 Click the **Users** tab, then do the following: -a Add user **Writer1** with Default and Writers roles. -b Add user **Writer2** with Default and Writers roles. -c Add user **NonWriter** with Default and NonWriters roles. - -5 Create a unique password for each user. - -With security in place, you can put an attribute on advise and see updates, without logging on. But to write to an attribute, even one with simple Operate security, you must log on as Writer1 or Writer2. If you are logged on as NonWriter, an attempt to write produces an error message with error code 1008. - -To exercise the secured write and verified write operations, you can edit ud1 and add two more attributes. - -**To add more attributes** - -1 Click the **UDAs** tab. - a Add y, of data type Integer, with security mode of Secured Write. - b Add z, of data type Integer, with security mode of Verified Write. - -2 Click the **Scripts** tab, and then add the following lines to the existing script s1: - ``` - me.y = me.y + 1; - me.z = me.z + 1; - ``` - -3 Save and close the editor. - -4 Undeploy and re-deploy ud1. - -This adds ud1.y and ud1.z as attributes that you can advise and write, using the appropriate secured or verified write options. - -The sample application does a Write() first to obtain a status using the OnWriteComplete() event and from that status determines whether the attribute requires a secured write or a verified write, then attempts the appropriate call to WriteSecured() - or reports an error status. If you are running the application in debug mode, you can set breakpoints inside the OnDataChange() event handler to trace through the code and see this process in action. - -Setting Up a Simple Galaxy Configuration for the Sample Applications # Example Security for the Role of Writers - -The following dialog box shows how you can configure security for the role of writers. - -Screenshot of the "Configure Security" dialog box showing roles and permissions configuration. - -# Example Security for the Role of NonWriters - -The following dialog box shows how you can configure security for the role of non-writers. - -Screenshot of the "Configure Security" dialog box showing roles and permissions configuration. - -# Appendix A: Status and Error Codes - -MXAccess status and error codes report on the health of the application. - -The status and error codes are presented in one of four types: - -* MxStatusDetail Values -* MxStatusCategory Values -* MxStatusSource Values -* ResolutionStatus Values - -An MxStatus is actually a structure, which contains four data fields: - -```c -typedef struct MxStatus { - VARIANT_BOOL success; - MxStatusCategory category; - MxStatusSource detectedBy; - short detail; -} MxStatus; -``` - -## MxStatusDetail Values - -The MxStatusDetail values are shown in the following table: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueStatus
0MX_S_Success
1MX_E_RequestTimedOut
2MX_E_PlatformCommunicationError
3MX_E_InvalidPlatformId
4MX_E_InvalidEngineId
5MX_E_EngineCommunicationError
6MX_E_InvalidReference
7MX_E_NoGalaxyRepository
8MX_E_InvalidObjectId
9MX_E_ObjectSignatureMismatch
10MX_E_AttributeSignatureMismatch
11MX_E_ResolvingAttribute
12MX_E_ResolvingObject
13MX_E_WrongDataType
14MX_E_WrongNumberOfDimensions
15MX_E_InvalidIndex
16MX_E_IndexOutOfOrder
17MX_E_DimensionDoesNotExist
18MX_E_ConversionNotSupported
19MX_E_UnableToConvertString
20MX_E_Overflow
21MX_E_NmxVersionMismatch
22MX_E_NmxInvalidCommand
23MX_E_LmxVersionMismatch
24MX_E_LmxInvalidCommand
25MX_E_GalaxyRepositoryBusy
26MX_E_EngineOverloaded
1000MX_E_InvalidPrimitiveId
1001MX_E_InvalidAttributeId
1002MX_E_InvalidPropertyId
1003MX_E_IndexOutOfRange
1004MX_E_DataOutOfRange
1005MX_E_IncorrectDataType
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueStatus
1006MX_E_NotReadable
1007MX_E_NotWriteable
1008MX_E_WriteAccessDenied
1009MX_E_UnknownError
1010MX_E_ObjectInitializing
1011MX_E_EngineInitializing
1012MX_E_SecuredWrite
1013MX_E_VerifiedWrite
1014MX_E_NoAlarmAckPrivilege
8000MX_E_AutomationObjectSpecificError
- -## MxStatusCategory Values - -The MxStatusCategory values are shown in the following table: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueStatus
-1MxStatusCategoryUnknown
0MxCategoryOk
1MxCategoryPending
2MxCategoryWarning
3MxCategoryCommunicationError
4MxCategoryConfigurationError
5MxCategoryOperationalError
6MxCategorySecurityError
7MxCategorySoftwareError
8MxCategoryOtherError
- -## MxStatusSource Values - -The MxStatusSource values are shown in the following table: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueStatus
-1MxSourceUnknown
0MxSourceRequestingLmx
1MxSourceRespondingLmx
2MxSourceRequestingNmx
3MxSourceRespondingNmx
4MxSourceRequestingAutomationObject
5MxSourceRespondingAutomationObject
- -## ResolutionStatus Values - -The ResolutionStatus values are shown in the following table: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueStatus
0unresolved
1resolvingObject
2resolvingAttribute
3resolved
4invalidReference
5noGalaxyRepository
6waitingToResolveAgainstDb
7waitingToResolveAttribute
8retrievingRedundancyStatus
diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs deleted file mode 100644 index d72fa71..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -///

-/// TLS configuration for LmxProxy client connections -/// -public class ClientTlsConfiguration -{ - /// - /// Gets or sets whether to use TLS for the connection - /// - public bool UseTls { get; set; } = false; - - /// - /// Gets or sets the path to the client certificate file (optional for mutual TLS) - /// - public string? ClientCertificatePath { get; set; } - - /// - /// Gets or sets the path to the client private key file (optional for mutual TLS) - /// - public string? ClientKeyPath { get; set; } - - /// - /// Gets or sets the path to the CA certificate for server validation (optional) - /// - public string? ServerCaCertificatePath { get; set; } - - /// - /// Gets or sets the server name override for certificate validation (optional) - /// - public string? ServerNameOverride { get; set; } - - /// - /// Gets or sets whether to validate the server certificate - /// - public bool ValidateServerCertificate { get; set; } = true; - - /// - /// Gets or sets whether to allow self-signed certificates (for testing only) - /// - public bool AllowSelfSignedCertificates { get; set; } = false; - - /// - /// Gets or sets whether to ignore all certificate errors (DANGEROUS - for testing only) - /// WARNING: This completely disables certificate validation and should never be used in production - /// - public bool IgnoreAllCertificateErrors { get; set; } = false; -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs deleted file mode 100644 index 618b417..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// -/// Represents the connection state of an LmxProxy client. -/// -public enum ConnectionState -{ - /// Not connected to the server. - Disconnected, - - /// Connection attempt in progress. - Connecting, - - /// Connected and ready for operations. - Connected, - - /// Graceful disconnect in progress. - Disconnecting, - - /// Connection failed with an error. - Error, - - /// Attempting to re-establish a lost connection. - Reconnecting -} - -/// -/// Event arguments for connection state change notifications. -/// -public class ConnectionStateChangedEventArgs : EventArgs -{ - /// The previous connection state. - public ConnectionState OldState { get; } - - /// The new connection state. - public ConnectionState NewState { get; } - - /// Optional message describing the state change (e.g., error details). - public string? Message { get; } - - public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null) - { - OldState = oldState; - NewState = newState; - Message = message; - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs deleted file mode 100644 index 1da3084..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs +++ /dev/null @@ -1,118 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// -/// OPC-style quality codes for SCADA data values. -/// Based on OPC DA quality encoding as a single byte: -/// bits 7–6 = major (00=Bad, 01=Uncertain, 11=Good), -/// bits 5–2 = substatus, bits 1–0 = limit (00=None, 01=Low, 10=High, 11=Constant). -/// -public enum Quality : byte -{ - /// Bad – non-specific. - Bad = 0, - - /// Bad – configuration error in the server. - Bad_ConfigError = 4, - - /// Bad – input source is not connected. - Bad_NotConnected = 8, - - /// Bad – device failure detected. - Bad_DeviceFailure = 12, - - /// Bad – sensor failure detected. - Bad_SensorFailure = 16, - - /// Bad – last known value (communication lost, value stale). - Bad_LastKnownValue = 20, - - /// Bad – communication failure. - Bad_CommFailure = 24, - - /// Bad – item is out of service. - Bad_OutOfService = 28, - - /// Uncertain – non-specific. - Uncertain = 64, - - /// Uncertain – non-specific, low limited. - Uncertain_LowLimited = 65, - - /// Uncertain – non-specific, high limited. - Uncertain_HighLimited = 66, - - /// Uncertain – non-specific, constant. - Uncertain_Constant = 67, - - /// Uncertain – last usable value. - Uncertain_LastUsable = 68, - - /// Uncertain – last usable value, low limited. - Uncertain_LastUsable_LL = 69, - - /// Uncertain – last usable value, high limited. - Uncertain_LastUsable_HL = 70, - - /// Uncertain – last usable value, constant. - Uncertain_LastUsable_Cnst = 71, - - /// Uncertain – sensor not accurate. - Uncertain_SensorNotAcc = 80, - - /// Uncertain – sensor not accurate, low limited. - Uncertain_SensorNotAcc_LL = 81, - - /// Uncertain – sensor not accurate, high limited. - Uncertain_SensorNotAcc_HL = 82, - - /// Uncertain – sensor not accurate, constant. - Uncertain_SensorNotAcc_C = 83, - - /// Uncertain – engineering units exceeded. - Uncertain_EuExceeded = 84, - - /// Uncertain – engineering units exceeded, low limited. - Uncertain_EuExceeded_LL = 85, - - /// Uncertain – engineering units exceeded, high limited. - Uncertain_EuExceeded_HL = 86, - - /// Uncertain – engineering units exceeded, constant. - Uncertain_EuExceeded_C = 87, - - /// Uncertain – sub-normal operating conditions. - Uncertain_SubNormal = 88, - - /// Uncertain – sub-normal, low limited. - Uncertain_SubNormal_LL = 89, - - /// Uncertain – sub-normal, high limited. - Uncertain_SubNormal_HL = 90, - - /// Uncertain – sub-normal, constant. - Uncertain_SubNormal_C = 91, - - /// Good – non-specific. - Good = 192, - - /// Good – low limited. - Good_LowLimited = 193, - - /// Good – high limited. - Good_HighLimited = 194, - - /// Good – constant. - Good_Constant = 195, - - /// Good – local override active. - Good_LocalOverride = 216, - - /// Good – local override active, low limited. - Good_LocalOverride_LL = 217, - - /// Good – local override active, high limited. - Good_LocalOverride_HL = 218, - - /// Good – local override active, constant. - Good_LocalOverride_C = 219 -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs deleted file mode 100644 index 9f87647..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -public static class QualityExtensions -{ - public static bool IsGood(this Quality q) => (byte)q >= 128; - public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128; - public static bool IsBad(this Quality q) => (byte)q < 64; -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs deleted file mode 100644 index bebfbc4..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs +++ /dev/null @@ -1,444 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.ServiceModel; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -// ──────────────────────────────────────────────────────────────── -// Service contract -// ──────────────────────────────────────────────────────────────── - -/// -/// Code-first gRPC service contract for SCADA operations. -/// -[ServiceContract(Name = "scada.ScadaService")] -public interface IScadaService -{ - /// Establishes a connection with the SCADA service. - ValueTask ConnectAsync(ConnectRequest request); - - /// Terminates a SCADA service connection. - ValueTask DisconnectAsync(DisconnectRequest request); - - /// Retrieves the current state of a SCADA connection. - ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); - - /// Reads a single tag value from the SCADA system. - ValueTask ReadAsync(ReadRequest request); - - /// Reads multiple tag values from the SCADA system in a batch operation. - ValueTask ReadBatchAsync(ReadBatchRequest request); - - /// Writes a single value to a tag in the SCADA system. - ValueTask WriteAsync(WriteRequest request); - - /// Writes multiple values to tags in the SCADA system in a batch operation. - ValueTask WriteBatchAsync(WriteBatchRequest request); - - /// Writes multiple values and waits for a completion flag before returning. - ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); - - /// Subscribes to real-time value changes from specified tags. - IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); - - /// Validates an API key for authentication. - ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); -} - -// ──────────────────────────────────────────────────────────────── -// VTQ message -// ──────────────────────────────────────────────────────────────── - -/// -/// Value-Timestamp-Quality message transmitted over gRPC. -/// All values are string-encoded; timestamps are UTC ticks. -/// -[DataContract] -public class VtqMessage -{ - /// Tag address. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - /// Value encoded as a string. - [DataMember(Order = 2)] - public string Value { get; set; } = string.Empty; - - /// UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01). - [DataMember(Order = 3)] - public long TimestampUtcTicks { get; set; } - - /// Quality string: "Good", "Uncertain", or "Bad". - [DataMember(Order = 4)] - public string Quality { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// Connect -// ──────────────────────────────────────────────────────────────── - -/// Request to establish a session with the proxy server. -[DataContract] -public class ConnectRequest -{ - /// Client identifier (e.g., "ScadaLink-{guid}"). - [DataMember(Order = 1)] - public string ClientId { get; set; } = string.Empty; - - /// API key for authentication (empty if none required). - [DataMember(Order = 2)] - public string ApiKey { get; set; } = string.Empty; -} - -/// Response from a Connect call. -[DataContract] -public class ConnectResponse -{ - /// Whether the connection was established successfully. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - /// Session ID (32-char hex GUID). Only valid when is true. - [DataMember(Order = 3)] - public string SessionId { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// Disconnect -// ──────────────────────────────────────────────────────────────── - -/// Request to terminate a session. -[DataContract] -public class DisconnectRequest -{ - /// Active session ID to disconnect. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -/// Response from a Disconnect call. -[DataContract] -public class DisconnectResponse -{ - /// Whether the disconnect succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// GetConnectionState -// ──────────────────────────────────────────────────────────────── - -/// Request to query connection state for a session. -[DataContract] -public class GetConnectionStateRequest -{ - /// Session ID to query. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -/// Response with connection state information. -[DataContract] -public class GetConnectionStateResponse -{ - /// Whether the session is currently connected. - [DataMember(Order = 1)] - public bool IsConnected { get; set; } - - /// Client identifier for this session. - [DataMember(Order = 2)] - public string ClientId { get; set; } = string.Empty; - - /// UTC ticks when the connection was established. - [DataMember(Order = 3)] - public long ConnectedSinceUtcTicks { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Read -// ──────────────────────────────────────────────────────────────── - -/// Request to read a single tag. -[DataContract] -public class ReadRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag address to read. - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; -} - -/// Response from a single-tag Read call. -[DataContract] -public class ReadResponse -{ - /// Whether the read succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Error message if the read failed. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - /// The value-timestamp-quality result. - [DataMember(Order = 3)] - public VtqMessage? Vtq { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// ReadBatch -// ──────────────────────────────────────────────────────────────── - -/// Request to read multiple tags in a single round-trip. -[DataContract] -public class ReadBatchRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag addresses to read. - [DataMember(Order = 2)] - public List Tags { get; set; } = []; -} - -/// Response from a batch Read call. -[DataContract] -public class ReadBatchResponse -{ - /// False if any tag read failed. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - /// VTQ results in the same order as the request tags. - [DataMember(Order = 3)] - public List Vtqs { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// Write -// ──────────────────────────────────────────────────────────────── - -/// Request to write a single tag value. -[DataContract] -public class WriteRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag address to write. - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; - - /// Value as a string (parsed server-side). - [DataMember(Order = 3)] - public string Value { get; set; } = string.Empty; -} - -/// Response from a single-tag Write call. -[DataContract] -public class WriteResponse -{ - /// Whether the write succeeded. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// WriteItem / WriteResult -// ──────────────────────────────────────────────────────────────── - -/// A single tag-value pair for batch write operations. -[DataContract] -public class WriteItem -{ - /// Tag address. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - /// Value as a string. - [DataMember(Order = 2)] - public string Value { get; set; } = string.Empty; -} - -/// Per-item result from a batch write operation. -[DataContract] -public class WriteResult -{ - /// Tag address that was written. - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - /// Whether the individual write succeeded. - [DataMember(Order = 2)] - public bool Success { get; set; } - - /// Error message for this item, if any. - [DataMember(Order = 3)] - public string Message { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// WriteBatch -// ──────────────────────────────────────────────────────────────── - -/// Request to write multiple tag values in a single round-trip. -[DataContract] -public class WriteBatchRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag-value pairs to write. - [DataMember(Order = 2)] - public List Items { get; set; } = []; -} - -/// Response from a batch Write call. -[DataContract] -public class WriteBatchResponse -{ - /// Overall success — false if any item failed. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - /// Per-item write results. - [DataMember(Order = 3)] - public List Results { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// WriteBatchAndWait -// ──────────────────────────────────────────────────────────────── - -/// -/// Request to write multiple tag values then poll a flag tag -/// until it matches an expected value or the timeout expires. -/// -[DataContract] -public class WriteBatchAndWaitRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag-value pairs to write. - [DataMember(Order = 2)] - public List Items { get; set; } = []; - - /// Tag to poll after writes complete. - [DataMember(Order = 3)] - public string FlagTag { get; set; } = string.Empty; - - /// Expected value for the flag tag (string comparison). - [DataMember(Order = 4)] - public string FlagValue { get; set; } = string.Empty; - - /// Timeout in milliseconds (default 5000 if <= 0). - [DataMember(Order = 5)] - public int TimeoutMs { get; set; } - - /// Poll interval in milliseconds (default 100 if <= 0). - [DataMember(Order = 6)] - public int PollIntervalMs { get; set; } -} - -/// Response from a WriteBatchAndWait call. -[DataContract] -public class WriteBatchAndWaitResponse -{ - /// Overall operation success. - [DataMember(Order = 1)] - public bool Success { get; set; } - - /// Status or error message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - /// Per-item write results. - [DataMember(Order = 3)] - public List WriteResults { get; set; } = []; - - /// Whether the flag tag matched the expected value before timeout. - [DataMember(Order = 4)] - public bool FlagReached { get; set; } - - /// Total elapsed time in milliseconds. - [DataMember(Order = 5)] - public int ElapsedMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Subscribe -// ──────────────────────────────────────────────────────────────── - -/// Request to subscribe to value change notifications on one or more tags. -[DataContract] -public class SubscribeRequest -{ - /// Valid session ID. - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - /// Tag addresses to monitor. - [DataMember(Order = 2)] - public List Tags { get; set; } = []; - - /// Backend sampling interval in milliseconds. - [DataMember(Order = 3)] - public int SamplingMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// CheckApiKey -// ──────────────────────────────────────────────────────────────── - -/// Request to validate an API key without creating a session. -[DataContract] -public class CheckApiKeyRequest -{ - /// API key to validate. - [DataMember(Order = 1)] - public string ApiKey { get; set; } = string.Empty; -} - -/// Response from an API key validation check. -[DataContract] -public class CheckApiKeyResponse -{ - /// Whether the API key is valid. - [DataMember(Order = 1)] - public bool IsValid { get; set; } - - /// Validation message. - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs deleted file mode 100644 index 34b007a..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// -/// Value, Timestamp, and Quality structure for SCADA data. -/// -/// The value. -/// The timestamp when the value was read. -/// The quality of the value. -public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) -{ - /// Creates a new VTQ with the specified value and quality, using the current UTC timestamp. - public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); - - /// Creates a new VTQ with the specified value, timestamp, and quality. - public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - - /// Creates a Good-quality VTQ with the current UTC time. - public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good); - - /// Creates a Bad-quality VTQ with the current UTC time. - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - - /// Creates an Uncertain-quality VTQ with the current UTC time. - public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain); -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs deleted file mode 100644 index 9e211c5..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Interface for LmxProxy client operations - /// - public interface ILmxProxyClient : IDisposable, IAsyncDisposable - { - /// - /// Gets or sets the default timeout for operations - /// - TimeSpan DefaultTimeout { get; set; } - - /// - /// Connects to the LmxProxy service - /// - /// Cancellation token. - Task ConnectAsync(CancellationToken cancellationToken = default); - - /// - /// Disconnects from the LmxProxy service - /// - Task DisconnectAsync(); - - /// - /// Checks if the client is connected to the service - /// - Task IsConnectedAsync(); - - /// - /// Reads a single tag value - /// - /// The tag address to read. - /// Cancellation token. - Task ReadAsync(string address, CancellationToken cancellationToken = default); - - /// - /// Reads multiple tag values in a single batch - /// - /// The tag addresses to read. - /// Cancellation token. - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); - - /// - /// Writes a single tag value - /// - /// The tag address to write. - /// The value to write. - /// Cancellation token. - Task WriteAsync(string address, object value, CancellationToken cancellationToken = default); - - /// - /// Writes multiple tag values in a single batch - /// - /// The tag addresses and values to write. - /// Cancellation token. - Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); - - /// - /// Subscribes to tag updates - /// - /// The tag addresses to subscribe to. - /// Callback invoked when tag values change. - /// Cancellation token. - Task SubscribeAsync(IEnumerable addresses, Action onUpdate, CancellationToken cancellationToken = default); - - /// - /// Gets the current metrics snapshot - /// - Dictionary GetMetrics(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs deleted file mode 100644 index c006b9c..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Linq; -using Microsoft.Extensions.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Factory interface for creating LmxProxyClient instances - /// - public interface ILmxProxyClientFactory - { - /// - /// Creates a new LmxProxyClient instance with default configuration - /// - /// A configured LmxProxyClient instance - LmxProxyClient CreateClient(); - - /// - /// Creates a new LmxProxyClient instance with custom configuration - /// - /// Name of the configuration section to use - /// A configured LmxProxyClient instance - LmxProxyClient CreateClient(string configurationName); - - /// - /// Creates a new LmxProxyClient instance using a builder - /// - /// Action to configure the builder - /// A configured LmxProxyClient instance - LmxProxyClient CreateClient(Action builderAction); - } - - /// - /// Default implementation of ILmxProxyClientFactory - /// - public class LmxProxyClientFactory : ILmxProxyClientFactory - { - private readonly IConfiguration _configuration; - - /// - /// Initializes a new instance of the LmxProxyClientFactory - /// - /// Application configuration - public LmxProxyClientFactory(IConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - /// - /// Creates a new LmxProxyClient instance with default configuration - /// - /// A configured LmxProxyClient instance - public LmxProxyClient CreateClient() - { - return CreateClient("LmxProxy"); - } - - /// - /// Creates a new LmxProxyClient instance with custom configuration - /// - /// Name of the configuration section to use - /// A configured LmxProxyClient instance - public LmxProxyClient CreateClient(string configurationName) - { - IConfigurationSection section = _configuration.GetSection(configurationName); - if (!section.GetChildren().Any() && section.Value == null) - { - throw new InvalidOperationException($"Configuration section '{configurationName}' not found"); - } - - var builder = new LmxProxyClientBuilder(); - - // Configure from appsettings - string? host = section["Host"]; - if (!string.IsNullOrEmpty(host)) - { - builder.WithHost(host); - } - - if (int.TryParse(section["Port"], out int port)) - { - builder.WithPort(port); - } - - string? apiKey = section["ApiKey"]; - if (!string.IsNullOrEmpty(apiKey)) - { - builder.WithApiKey(apiKey); - } - - if (TimeSpan.TryParse(section["Timeout"], out TimeSpan timeout)) - { - builder.WithTimeout(timeout); - } - - // Retry configuration - IConfigurationSection? retrySection = section.GetSection("Retry"); - if (retrySection != null && (retrySection.GetChildren().Any() || retrySection.Value != null)) - { - if (int.TryParse(retrySection["MaxAttempts"], out int maxAttempts) && - TimeSpan.TryParse(retrySection["Delay"], out TimeSpan retryDelay)) - { - builder.WithRetryPolicy(maxAttempts, retryDelay); - } - } - - // SSL configuration - bool useSsl = section.GetValue("UseSsl"); - if (useSsl) - { - string? certificatePath = section["CertificatePath"]; - builder.WithSslCredentials(certificatePath); - } - - // Metrics configuration - if (section.GetValue("EnableMetrics")) - { - builder.WithMetrics(); - } - - // Correlation ID configuration - string? correlationHeader = section["CorrelationIdHeader"]; - if (!string.IsNullOrEmpty(correlationHeader)) - { - builder.WithCorrelationIdHeader(correlationHeader); - } - - // Logger is optional - don't set a default one - - return builder.Build(); - } - - /// - /// Creates a new LmxProxyClient instance using a builder - /// - /// Action to configure the builder - /// A configured LmxProxyClient instance - public LmxProxyClient CreateClient(Action builderAction) - { - ArgumentNullException.ThrowIfNull(builderAction); - - var builder = new LmxProxyClientBuilder(); - builderAction(builder); - - // Logger is optional - caller can set it via builderAction if needed - - return builder.Build(); - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs deleted file mode 100644 index d184e59..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// API key information returned from CheckApiKey - /// - public class ApiKeyInfo - { - /// - /// Whether the API key is valid - /// - public bool IsValid { get; } - - /// - /// The role assigned to the API key - /// - public string Role { get; } - - /// - /// Description of the API key - /// - public string Description { get; } - - /// - /// Initializes a new instance of the ApiKeyInfo class - /// - /// Whether the API key is valid - /// The role assigned to the API key - /// Description of the API key - public ApiKeyInfo(bool isValid, string role, string description) - { - IsValid = isValid; - Role = role ?? string.Empty; - Description = description ?? string.Empty; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs deleted file mode 100644 index 3dd6600..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Metrics collection for client operations - /// - internal class ClientMetrics - { - private readonly ConcurrentDictionary _operationCounts = new(); - private readonly ConcurrentDictionary _errorCounts = new(); - private readonly ConcurrentDictionary> _latencies = new(); - private readonly object _latencyLock = new(); - - /// - /// Increments the operation count for a specific operation. - /// - /// The operation name. - public void IncrementOperationCount(string operation) - { - _operationCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1); - } - - /// - /// Increments the error count for a specific operation. - /// - /// The operation name. - public void IncrementErrorCount(string operation) - { - _errorCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1); - } - - /// - /// Records latency for a specific operation. - /// - /// The operation name. - /// The latency in milliseconds. - public void RecordLatency(string operation, long milliseconds) - { - lock (_latencyLock) - { - if (!_latencies.ContainsKey(operation)) - { - _latencies[operation] = []; - } - _latencies[operation].Add(milliseconds); - - // Keep only last 1000 entries to prevent memory growth - if (_latencies[operation].Count > 1000) - { - _latencies[operation].RemoveAt(0); - } - } - } - - /// - /// Gets a snapshot of current metrics. - /// - /// A dictionary containing metric data. - public Dictionary GetSnapshot() - { - var snapshot = new Dictionary(); - - foreach (KeyValuePair kvp in _operationCounts) - { - snapshot[$"{kvp.Key}_count"] = kvp.Value; - } - - foreach (KeyValuePair kvp in _errorCounts) - { - snapshot[$"{kvp.Key}_errors"] = kvp.Value; - } - - lock (_latencyLock) - { - foreach (KeyValuePair> kvp in _latencies) - { - if (kvp.Value.Any()) - { - snapshot[$"{kvp.Key}_avg_latency_ms"] = kvp.Value.Average(); - snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(kvp.Value, 95); - snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(kvp.Value, 99); - } - } - } - - return snapshot; - } - - private double GetPercentile(List values, int percentile) - { - var sorted = values.OrderBy(x => x).ToList(); - int index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1; - return sorted[Math.Max(0, index)]; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs deleted file mode 100644 index 02e84df..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - public partial class LmxProxyClient - { - private class CodeFirstSubscription : ISubscription - { - private readonly IScadaService _client; - private readonly string _sessionId; - private readonly List _tags; - private readonly Action _onUpdate; - private readonly ILogger _logger; - private readonly Action? _onDispose; - private readonly CancellationTokenSource _cts = new(); - private Task? _processingTask; - private bool _disposed; - - /// - /// Initializes a new instance of the CodeFirstSubscription class. - /// - /// The gRPC ScadaService client. - /// The session identifier. - /// The list of tag addresses to subscribe to. - /// Callback invoked when tag values change. - /// Logger for diagnostic information. - /// Optional callback invoked when the subscription is disposed. - public CodeFirstSubscription( - IScadaService client, - string sessionId, - List tags, - Action onUpdate, - ILogger logger, - Action? onDispose = null) - { - _client = client; - _sessionId = sessionId; - _tags = tags; - _onUpdate = onUpdate; - _logger = logger; - _onDispose = onDispose; - } - - /// - /// Starts the subscription asynchronously and begins processing tag value updates. - /// - /// Cancellation token. - /// A task that completes when the subscription processing has started. - public Task StartAsync(CancellationToken cancellationToken = default) - { - _processingTask = ProcessUpdatesAsync(cancellationToken); - return Task.CompletedTask; - } - - private async Task ProcessUpdatesAsync(CancellationToken cancellationToken) - { - try - { - var request = new SubscribeRequest - { - SessionId = _sessionId, - Tags = _tags, - SamplingMs = 1000 - }; - - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); - - await foreach (VtqMessage vtq in _client.SubscribeAsync(request, linkedCts.Token)) - { - try - { - Vtq convertedVtq = ConvertToVtq(vtq.Tag, vtq); - _onUpdate(vtq.Tag, convertedVtq); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing subscription update for {Tag}", vtq.Tag); - } - } - } - catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested || cancellationToken.IsCancellationRequested) - { - _logger.LogDebug("Subscription cancelled"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in subscription processing"); - try { await _cts.CancelAsync(); } catch { /* ignore */ } - } - finally - { - if (!_disposed) - { - _disposed = true; - _onDispose?.Invoke(this); - } - } - } - - /// - /// Asynchronously disposes the subscription and stops processing tag updates. - /// - /// A task representing the asynchronous disposal operation. - public async Task DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - await _cts.CancelAsync(); - - try - { - if (_processingTask != null) - { - await _processingTask; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error disposing subscription"); - } - finally - { - _cts.Dispose(); - _onDispose?.Invoke(this); - } - } - - /// - /// Synchronously disposes the subscription and stops processing tag updates. - /// - public void Dispose() - { - if (_disposed) return; - - try - { - Task task = DisposeAsync(); - if (!task.Wait(TimeSpan.FromSeconds(5))) - { - _logger.LogWarning("Subscription disposal timed out"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during synchronous disposal"); - } - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs deleted file mode 100644 index ead33f5..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; -using ProtoBuf.Grpc.Client; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Security; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - public partial class LmxProxyClient - { - /// - /// Connects to the LmxProxy service and establishes a session - /// - /// Cancellation token. - public async Task ConnectAsync(CancellationToken cancellationToken = default) - { - GrpcChannel? provisionalChannel = null; - - await _connectionLock.WaitAsync(cancellationToken); - try - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(LmxProxyClient)); - } - - if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId)) - { - _logger.LogDebug("LmxProxyClient already connected to {Host}:{Port} with session {SessionId}", - _host, _port, _sessionId); - return; - } - - string securityMode = _tlsConfiguration?.UseTls == true ? "TLS/SSL" : "INSECURE"; - _logger.LogInformation("Creating new {SecurityMode} connection to LmxProxy at {Host}:{Port}", - securityMode, _host, _port); - - Uri endpoint = BuildEndpointUri(); - provisionalChannel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger); - - // Create code-first gRPC client - IScadaService provisionalClient = provisionalChannel.CreateGrpcService(); - - // Establish session with the server - var connectRequest = new ConnectRequest - { - ClientId = $"ScadaBridge-{Guid.NewGuid():N}", - ApiKey = _apiKey ?? string.Empty - }; - - ConnectResponse connectResponse = await provisionalClient.ConnectAsync(connectRequest); - - if (!connectResponse.Success) - { - provisionalChannel.Dispose(); - throw new InvalidOperationException($"Failed to establish session: {connectResponse.Message}"); - } - - // Dispose any existing channel before replacing it - _channel?.Dispose(); - - _channel = provisionalChannel; - _client = provisionalClient; - _sessionId = connectResponse.SessionId; - _isConnected = true; - - provisionalChannel = null; - - StartKeepAlive(); - - _logger.LogInformation("Successfully connected to LmxProxy with session {SessionId}", _sessionId); - } - catch (Exception ex) - { - _isConnected = false; - _client = null; - _sessionId = string.Empty; - _logger.LogError(ex, "Failed to connect to LmxProxy"); - throw; - } - finally - { - provisionalChannel?.Dispose(); - _connectionLock.Release(); - } - } - - private void StartKeepAlive() - { - StopKeepAlive(); - - _keepAliveTimer = new Timer(async _ => - { - try - { - if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId)) - { - // Send a lightweight ping to keep session alive - var request = new GetConnectionStateRequest { SessionId = _sessionId }; - await _client.GetConnectionStateAsync(request); - - _logger.LogDebug("Keep-alive ping sent successfully for session {SessionId}", _sessionId); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Keep-alive ping failed"); - StopKeepAlive(); - await MarkDisconnectedAsync(ex).ConfigureAwait(false); - } - }, null, _keepAliveInterval, _keepAliveInterval); - } - - private void StopKeepAlive() - { - _keepAliveTimer?.Dispose(); - _keepAliveTimer = null; - } - - /// - /// Disconnects from the LmxProxy service - /// - public async Task DisconnectAsync() - { - await _connectionLock.WaitAsync(); - try - { - StopKeepAlive(); - - if (_client != null && !string.IsNullOrEmpty(_sessionId)) - { - try - { - var request = new DisconnectRequest { SessionId = _sessionId }; - await _client.DisconnectAsync(request); - _logger.LogInformation("Session {SessionId} disconnected", _sessionId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error during disconnect"); - } - } - - _client = null; - _sessionId = string.Empty; - _isConnected = false; - - _channel?.Dispose(); - _channel = null; - } - finally - { - _connectionLock.Release(); - } - } - - /// - /// Connects the LmxProxy to MxAccess (legacy method - session now established in ConnectAsync) - /// - /// Cancellation token. - public Task<(bool Success, string? ErrorMessage)> ConnectToMxAccessAsync(CancellationToken cancellationToken = default) - { - // Session is now established in ConnectAsync - if (IsConnected) - return Task.FromResult((true, (string?)null)); - - return Task.FromResult<(bool Success, string? ErrorMessage)>((false, "Not connected. Call ConnectAsync first.")); - } - - /// - /// Disconnects the LmxProxy from MxAccess (legacy method) - /// - /// Cancellation token. - public async Task<(bool Success, string? ErrorMessage)> DisconnectFromMxAccessAsync(CancellationToken cancellationToken = default) - { - try - { - await DisconnectAsync(); - return (true, null); - } - catch (Exception ex) - { - return (false, ex.Message); - } - } - - /// - /// Gets the connection state of the LmxProxy - /// - /// Cancellation token. - public async Task<(bool IsConnected, string? ClientId)> GetConnectionStateAsync(CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var request = new GetConnectionStateRequest { SessionId = _sessionId }; - GetConnectionStateResponse response = await _client!.GetConnectionStateAsync(request); - return (response.IsConnected, response.ClientId); - } - - /// - /// Builds the gRPC endpoint URI (http/https) based on TLS configuration. - /// - private Uri BuildEndpointUri() - { - string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; - return new UriBuilder - { - Scheme = scheme, - Host = _host, - Port = _port - }.Uri; - } - - private async Task MarkDisconnectedAsync(Exception? ex = null) - { - if (_disposed) - return; - - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - _isConnected = false; - _client = null; - _sessionId = string.Empty; - _channel?.Dispose(); - _channel = null; - } - finally - { - _connectionLock.Release(); - } - - List subsToDispose; - lock (_subscriptionLock) - { - subsToDispose = new List(_activeSubscriptions); - _activeSubscriptions.Clear(); - } - - foreach (ISubscription sub in subsToDispose) - { - try - { - await sub.DisposeAsync().ConfigureAwait(false); - } - catch (Exception disposeEx) - { - _logger.LogWarning(disposeEx, "Error disposing subscription after disconnect"); - } - } - - if (ex != null) - { - _logger.LogWarning(ex, "Connection marked disconnected due to keep-alive failure"); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs deleted file mode 100644 index 3d909a5..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Represents a subscription to tag value changes - /// - public interface ISubscription : IDisposable - { - /// - /// Disposes the subscription asynchronously - /// - Task DisposeAsync(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs deleted file mode 100644 index 10a2164..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs +++ /dev/null @@ -1,573 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Polly; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Security; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Client for communicating with the LmxProxy gRPC service using protobuf-net.Grpc code-first - /// - public partial class LmxProxyClient : ILmxProxyClient - { - private static readonly string Http2InsecureSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport"; - private readonly ILogger _logger; - private readonly string _host; - private readonly int _port; - private readonly string? _apiKey; - private GrpcChannel? _channel; - private IScadaService? _client; - private string _sessionId = string.Empty; - private readonly SemaphoreSlim _connectionLock = new(1, 1); - private readonly List _activeSubscriptions = []; - private readonly Lock _subscriptionLock = new(); - private bool _disposed; - private bool _isConnected; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private ClientConfiguration? _configuration; - private IAsyncPolicy? _retryPolicy; - private readonly ClientMetrics _metrics = new(); - private Timer? _keepAliveTimer; - private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30); - private readonly ClientTlsConfiguration? _tlsConfiguration; - - static LmxProxyClient() - { - AppContext.SetSwitch(Http2InsecureSwitch, true); - } - - /// - /// Gets or sets the default timeout for operations - /// - public TimeSpan DefaultTimeout - { - get => _defaultTimeout; - set - { - if (value <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive"); - if (value > TimeSpan.FromMinutes(10)) - throw new ArgumentOutOfRangeException(nameof(value), "Timeout cannot exceed 10 minutes"); - _defaultTimeout = value; - } - } - - /// - /// Initializes a new instance of the LmxProxyClient - /// - /// The host address of the LmxProxy service - /// The port of the LmxProxy service - /// The API key for authentication - /// Optional logger instance - public LmxProxyClient(string host, int port, string? apiKey = null, ILogger? logger = null) - : this(host, port, apiKey, null, logger) - { - } - - /// - /// Creates a new instance of the LmxProxyClient with TLS configuration - /// - /// The host address of the LmxProxy service - /// The port of the LmxProxy service - /// The API key for authentication - /// TLS configuration for secure connections - /// Optional logger instance - public LmxProxyClient(string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger? logger = null) - { - if (string.IsNullOrWhiteSpace(host)) - throw new ArgumentException("Host cannot be null or empty", nameof(host)); - if (port < 1 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535"); - - _host = host; - _port = port; - _apiKey = apiKey; - _tlsConfiguration = tlsConfiguration; - _logger = logger ?? NullLogger.Instance; - } - - /// - /// Gets whether the client is connected to the service - /// - public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); - - /// - /// Asynchronously checks if the client is connected with proper synchronization - /// - public async Task IsConnectedAsync() - { - await _connectionLock.WaitAsync(); - try - { - return !_disposed && _client != null && _isConnected && !string.IsNullOrEmpty(_sessionId); - } - finally - { - _connectionLock.Release(); - } - } - - /// - /// Sets the builder configuration (internal use) - /// - /// The client configuration. - internal void SetBuilderConfiguration(ClientConfiguration configuration) - { - _configuration = configuration; - - // Setup retry policy if configured - if (configuration.MaxRetryAttempts > 0) - { - _retryPolicy = Policy - .Handle(IsTransientError) - .WaitAndRetryAsync( - configuration.MaxRetryAttempts, - retryAttempt => configuration.RetryDelay * Math.Pow(2, retryAttempt - 1), - onRetry: (exception, timeSpan, retryCount, context) => - { - object? correlationId = context.GetValueOrDefault("CorrelationId", "N/A"); - _logger.LogWarning(exception, - "Retry {RetryCount} after {Delay}ms. CorrelationId: {CorrelationId}", - retryCount, timeSpan.TotalMilliseconds, correlationId); - }); - } - } - - /// - /// Reads a single tag value - /// - /// The tag address to read. - /// Cancellation token. - public async Task ReadAsync(string address, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(address)) - throw new ArgumentNullException(nameof(address)); - - EnsureConnected(); - - string correlationId = GenerateCorrelationId(); - var stopwatch = Stopwatch.StartNew(); - - try - { - _metrics.IncrementOperationCount("Read"); - - var request = new ReadRequest - { - SessionId = _sessionId, - Tag = address - }; - - ReadResponse response = await ExecuteWithRetryAsync(async () => - await _client!.ReadAsync(request), - correlationId); - - if (!response.Success) - { - _metrics.IncrementErrorCount("Read"); - throw new InvalidOperationException($"Read failed for tag '{address}': {response.Message}. CorrelationId: {correlationId}"); - } - - _metrics.RecordLatency("Read", stopwatch.ElapsedMilliseconds); - return ConvertToVtq(address, response.Vtq); - } - catch (Exception ex) - { - _metrics.IncrementErrorCount("Read"); - _logger.LogError(ex, "Read operation failed for tag: {Tag}, CorrelationId: {CorrelationId}", - address, correlationId); - throw; - } - } - - /// - /// Reads multiple tag values - /// - /// The tag addresses to read. - /// Cancellation token. - public async Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(addresses); - - var addressList = addresses.ToList(); - if (!addressList.Any()) - throw new ArgumentException("At least one address must be provided", nameof(addresses)); - - EnsureConnected(); - - var request = new ReadBatchRequest - { - SessionId = _sessionId, - Tags = addressList - }; - - ReadBatchResponse response = await _client!.ReadBatchAsync(request); - - if (!response.Success) - throw new InvalidOperationException($"ReadBatch failed: {response.Message}"); - - var results = new Dictionary(); - foreach (VtqMessage vtq in response.Vtqs) - { - results[vtq.Tag] = ConvertToVtq(vtq.Tag, vtq); - } - return results; - } - - /// - /// Writes a single tag value - /// - /// The tag address to write. - /// The value to write. - /// Cancellation token. - public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(address)) - throw new ArgumentNullException(nameof(address)); - ArgumentNullException.ThrowIfNull(value); - - EnsureConnected(); - - var request = new WriteRequest - { - SessionId = _sessionId, - Tag = address, - Value = ConvertToString(value) - }; - - WriteResponse response = await _client!.WriteAsync(request); - - if (!response.Success) - throw new InvalidOperationException($"Write failed: {response.Message}"); - } - - /// - /// Writes multiple tag values - /// - /// The tag addresses and values to write. - /// Cancellation token. - public async Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) - { - if (values == null || !values.Any()) - throw new ArgumentException("At least one value must be provided", nameof(values)); - - EnsureConnected(); - - var request = new WriteBatchRequest - { - SessionId = _sessionId, - Items = values.Select(kvp => new WriteItem - { - Tag = kvp.Key, - Value = ConvertToString(kvp.Value) - }).ToList() - }; - - WriteBatchResponse response = await _client!.WriteBatchAsync(request); - - if (!response.Success) - throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); - } - - /// - /// Writes values and waits for a condition to be met - /// - /// The tag addresses and values to write. - /// The flag address to write. - /// The flag value to write. - /// The response address to monitor. - /// The expected response value. - /// Timeout in seconds. - /// Cancellation token. - public async Task WriteBatchAndWaitAsync( - IDictionary values, - string flagAddress, - object flagValue, - string responseAddress, - object responseValue, - int timeoutSeconds = 30, - CancellationToken cancellationToken = default) - { - if (values == null || !values.Any()) - throw new ArgumentException("At least one value must be provided", nameof(values)); - - EnsureConnected(); - - var request = new WriteBatchAndWaitRequest - { - SessionId = _sessionId, - Items = values.Select(kvp => new WriteItem - { - Tag = kvp.Key, - Value = ConvertToString(kvp.Value) - }).ToList(), - FlagTag = flagAddress, - FlagValue = ConvertToString(flagValue), - TimeoutMs = timeoutSeconds * 1000, - PollIntervalMs = 100 - }; - - WriteBatchAndWaitResponse response = await _client!.WriteBatchAndWaitAsync(request); - - if (!response.Success) - throw new InvalidOperationException($"WriteBatchAndWait failed: {response.Message}"); - - return response.FlagReached; - } - - /// - /// Checks the validity and permissions of the current API key - /// - /// Cancellation token. - public async Task CheckApiKeyAsync(CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var request = new CheckApiKeyRequest { ApiKey = _apiKey ?? string.Empty }; - CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request); - - return new ApiKeyInfo( - response.IsValid, - "ReadWrite", // Code-first contract doesn't return role - response.Message); - } - - /// - /// Subscribes to tag value changes - /// - /// The tag addresses to subscribe to. - /// Callback invoked when tag values change. - /// Cancellation token. - public Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - CancellationToken cancellationToken = default) - { - List addressList = addresses?.ToList() ?? throw new ArgumentNullException(nameof(addresses)); - if (!addressList.Any()) - throw new ArgumentException("At least one address must be provided", nameof(addresses)); - ArgumentNullException.ThrowIfNull(onUpdate); - - EnsureConnected(); - - var subscription = new CodeFirstSubscription(_client!, _sessionId, addressList, onUpdate, _logger, RemoveSubscription); - - // Track the subscription - lock (_subscriptionLock) - { - _activeSubscriptions.Add(subscription); - } - - // Start processing updates - Task startTask = subscription.StartAsync(cancellationToken); - - // Log any startup errors but don't throw - startTask.ContinueWith(t => - { - if (t.IsFaulted) - { - _logger.LogError(t.Exception, "Subscription startup failed"); - } - }, TaskContinuationOptions.OnlyOnFaulted); - - return Task.FromResult(subscription); - } - - private void EnsureConnected() - { - if (_disposed) - throw new ObjectDisposedException(nameof(LmxProxyClient)); - if (_client == null || !_isConnected || string.IsNullOrEmpty(_sessionId)) - throw new InvalidOperationException("Client is not connected. Call ConnectAsync first."); - } - - private static Vtq ConvertToVtq(string tag, VtqMessage? vtqMessage) - { - if (vtqMessage == null) - return new Vtq(null, DateTime.UtcNow, Quality.Bad); - - // Parse the string value - object? value = vtqMessage.Value; - if (!string.IsNullOrEmpty(vtqMessage.Value)) - { - // Try to parse as numeric types - if (double.TryParse(vtqMessage.Value, out double doubleVal)) - value = doubleVal; - else if (bool.TryParse(vtqMessage.Value, out bool boolVal)) - value = boolVal; - else - value = vtqMessage.Value; - } - - var timestamp = new DateTime(vtqMessage.TimestampUtcTicks, DateTimeKind.Utc); - Quality quality = vtqMessage.Quality?.ToUpperInvariant() switch - { - "GOOD" => Quality.Good, - "UNCERTAIN" => Quality.Uncertain, - _ => Quality.Bad - }; - - return new Vtq(value, timestamp, quality); - } - - private static string ConvertToString(object value) - { - if (value == null) - return string.Empty; - - return value switch - { - DateTime dt => dt.ToUniversalTime().ToString("O"), - DateTimeOffset dto => dto.ToString("O"), - bool b => b.ToString().ToLowerInvariant(), - _ => value.ToString() ?? string.Empty - }; - } - - /// - /// Removes a subscription from the active tracking list - /// - private void RemoveSubscription(ISubscription subscription) - { - lock (_subscriptionLock) - { - _activeSubscriptions.Remove(subscription); - } - } - - /// - /// Disposes of the client and closes the connection - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - DisposeAsync().AsTask().GetAwaiter().GetResult(); - GC.SuppressFinalize(this); - } - - /// - /// Asynchronously disposes of the client and closes the connection - /// - public async ValueTask DisposeAsync() - { - if (_disposed) - return; - - _disposed = true; - - await DisposeCoreAsync().ConfigureAwait(false); - _connectionLock.Dispose(); - GC.SuppressFinalize(this); - } - - /// - /// Protected disposal implementation - /// - /// True if disposing managed resources. - protected virtual void Dispose(bool disposing) - { - if (!disposing || _disposed) - return; - - _disposed = true; - - DisposeCoreAsync().GetAwaiter().GetResult(); - _connectionLock.Dispose(); - } - - private async Task DisposeCoreAsync() - { - StopKeepAlive(); - - List subscriptionsToDispose; - lock (_subscriptionLock) - { - subscriptionsToDispose = new List(_activeSubscriptions); - _activeSubscriptions.Clear(); - } - - foreach (ISubscription subscription in subscriptionsToDispose) - { - try - { - await subscription.DisposeAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error disposing subscription"); - } - } - - // Disconnect session - if (_client != null && !string.IsNullOrEmpty(_sessionId)) - { - try - { - var request = new DisconnectRequest { SessionId = _sessionId }; - await _client.DisconnectAsync(request); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error during disconnect on dispose"); - } - } - - await _connectionLock.WaitAsync().ConfigureAwait(false); - try - { - _client = null; - _sessionId = string.Empty; - _isConnected = false; - - _channel?.Dispose(); - _channel = null; - } - finally - { - _connectionLock.Release(); - } - } - - private string GenerateCorrelationId() - { - return Guid.NewGuid().ToString("N"); - } - - private bool IsTransientError(Exception ex) - { - // Check for transient gRPC errors - return ex.Message.Contains("Unavailable") || - ex.Message.Contains("DeadlineExceeded") || - ex.Message.Contains("ResourceExhausted") || - ex.Message.Contains("Aborted"); - } - - private async Task ExecuteWithRetryAsync(Func> operation, string correlationId) - { - if (_retryPolicy != null) - { - var context = new Context { ["CorrelationId"] = correlationId }; - return await _retryPolicy.ExecuteAsync(async (_) => await operation(), context); - } - - return await operation(); - } - - /// - /// Gets the current metrics snapshot - /// - public Dictionary GetMetrics() => _metrics.GetSnapshot(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs deleted file mode 100644 index cd4342c..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System; -using System.IO; -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Builder for creating configured instances of LmxProxyClient - /// - public class LmxProxyClientBuilder - { - private string? _host; - private int _port = 5050; - private string? _apiKey; - private ILogger? _logger; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private int _maxRetryAttempts = 3; - private TimeSpan _retryDelay = TimeSpan.FromSeconds(1); - private bool _enableMetrics; - private string? _correlationIdHeader; - private ClientTlsConfiguration? _tlsConfiguration; - - /// - /// Sets the host address for the LmxProxy service - /// - /// The host address - /// The builder instance for method chaining - public LmxProxyClientBuilder WithHost(string host) - { - if (string.IsNullOrWhiteSpace(host)) - throw new ArgumentException("Host cannot be null or empty", nameof(host)); - - _host = host; - return this; - } - - /// - /// Sets the port for the LmxProxy service - /// - /// The port number - /// The builder instance for method chaining - public LmxProxyClientBuilder WithPort(int port) - { - if (port < 1 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535"); - - _port = port; - return this; - } - - /// - /// Sets the API key for authentication - /// - /// The API key - /// The builder instance for method chaining - public LmxProxyClientBuilder WithApiKey(string apiKey) - { - _apiKey = apiKey; - return this; - } - - /// - /// Sets the logger instance - /// - /// The logger - /// The builder instance for method chaining - public LmxProxyClientBuilder WithLogger(ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - return this; - } - - /// - /// Sets the default timeout for operations - /// - /// The timeout duration - /// The builder instance for method chaining - public LmxProxyClientBuilder WithTimeout(TimeSpan timeout) - { - if (timeout <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive"); - if (timeout > TimeSpan.FromMinutes(10)) - throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout cannot exceed 10 minutes"); - - _defaultTimeout = timeout; - return this; - } - - /// - /// Enables SSL/TLS with the specified certificate - /// - /// Path to the certificate file - /// The builder instance for method chaining - public LmxProxyClientBuilder WithSslCredentials(string? certificatePath = null) - { - _tlsConfiguration ??= new ClientTlsConfiguration(); - _tlsConfiguration.UseTls = true; - _tlsConfiguration.ServerCaCertificatePath = string.IsNullOrWhiteSpace(certificatePath) ? null : certificatePath; - return this; - } - - /// - /// Applies a full TLS configuration to the client. - /// - /// The TLS configuration to apply. - /// The builder instance for method chaining. - public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration configuration) - { - _tlsConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - return this; - } - - /// - /// Sets the retry configuration - /// - /// Maximum number of retry attempts - /// Delay between retries - /// The builder instance for method chaining - public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay) - { - if (maxAttempts <= 0) - throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max attempts must be positive"); - if (retryDelay <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be positive"); - - _maxRetryAttempts = maxAttempts; - _retryDelay = retryDelay; - return this; - } - - /// - /// Enables metrics collection - /// - /// The builder instance for method chaining - public LmxProxyClientBuilder WithMetrics() - { - _enableMetrics = true; - return this; - } - - /// - /// Sets the correlation ID header name for request tracing - /// - /// The header name for correlation ID - /// The builder instance for method chaining - public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName) - { - if (string.IsNullOrEmpty(headerName)) - throw new ArgumentException("Header name cannot be null or empty", nameof(headerName)); - - _correlationIdHeader = headerName; - return this; - } - - /// - /// Builds the configured LmxProxyClient instance - /// - /// A configured LmxProxyClient instance - public LmxProxyClient Build() - { - if (string.IsNullOrWhiteSpace(_host)) - throw new InvalidOperationException("Host must be specified"); - - ValidateTlsConfiguration(); - - var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger) - { - DefaultTimeout = _defaultTimeout - }; - - // Store additional configuration for future use - client.SetBuilderConfiguration(new ClientConfiguration - { - MaxRetryAttempts = _maxRetryAttempts, - RetryDelay = _retryDelay, - EnableMetrics = _enableMetrics, - CorrelationIdHeader = _correlationIdHeader - }); - - return client; - } - - private void ValidateTlsConfiguration() - { - if (_tlsConfiguration?.UseTls != true) - { - return; - } - - if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ServerCaCertificatePath) && - !File.Exists(_tlsConfiguration.ServerCaCertificatePath)) - { - throw new FileNotFoundException( - $"Certificate file not found: {_tlsConfiguration.ServerCaCertificatePath}", - _tlsConfiguration.ServerCaCertificatePath); - } - - if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCertificatePath) && - !File.Exists(_tlsConfiguration.ClientCertificatePath)) - { - throw new FileNotFoundException( - $"Client certificate file not found: {_tlsConfiguration.ClientCertificatePath}", - _tlsConfiguration.ClientCertificatePath); - } - - if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientKeyPath) && - !File.Exists(_tlsConfiguration.ClientKeyPath)) - { - throw new FileNotFoundException( - $"Client key file not found: {_tlsConfiguration.ClientKeyPath}", - _tlsConfiguration.ClientKeyPath); - } - } - } - - /// - /// Internal configuration class for storing builder settings - /// - internal class ClientConfiguration - { - /// - /// Gets or sets the maximum number of retry attempts. - /// - public int MaxRetryAttempts { get; set; } - - /// - /// Gets or sets the retry delay. - /// - public TimeSpan RetryDelay { get; set; } - - /// - /// Gets or sets a value indicating whether metrics are enabled. - /// - public bool EnableMetrics { get; set; } - - /// - /// Gets or sets the correlation ID header name. - /// - public string? CorrelationIdHeader { get; set; } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs deleted file mode 100644 index 9157023..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Runtime.CompilerServices; - -// Expose internal members to test assembly -[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")] diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs deleted file mode 100644 index 9c4443b..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.LmxProxy.Client.Security; - -internal static class GrpcChannelFactory -{ - private const string Http2UnencryptedSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport"; - - static GrpcChannelFactory() - { - AppContext.SetSwitch(Http2UnencryptedSwitch, true); - } - - /// - /// Creates a gRPC channel with optional TLS configuration. - /// - /// The server address. - /// Optional TLS configuration. - /// The logger. - /// A configured gRPC channel. - public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger) - { - var options = new GrpcChannelOptions - { - HttpHandler = CreateHttpHandler(tlsConfiguration, logger) - }; - - return GrpcChannel.ForAddress(address, options); - } - - private static HttpMessageHandler CreateHttpHandler(ClientTlsConfiguration? tlsConfiguration, ILogger logger) - { - var handler = new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.None, - AllowAutoRedirect = false, - EnableMultipleHttp2Connections = true - }; - - if (tlsConfiguration?.UseTls == true) - { - ConfigureTls(handler, tlsConfiguration, logger); - } - - return handler; - } - - private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tlsConfiguration, ILogger logger) - { - SslClientAuthenticationOptions sslOptions = handler.SslOptions; - sslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13; - - if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride)) - { - sslOptions.TargetHost = tlsConfiguration.ServerNameOverride; - } - - if (!string.IsNullOrWhiteSpace(tlsConfiguration.ClientCertificatePath) && - !string.IsNullOrWhiteSpace(tlsConfiguration.ClientKeyPath)) - { - try - { - var clientCertificate = X509Certificate2.CreateFromPemFile( - tlsConfiguration.ClientCertificatePath, - tlsConfiguration.ClientKeyPath); - clientCertificate = new X509Certificate2(clientCertificate.Export(X509ContentType.Pfx)); - - sslOptions.ClientCertificates ??= new X509CertificateCollection(); - sslOptions.ClientCertificates.Add(clientCertificate); - logger.LogInformation("Configured client certificate for mutual TLS ({CertificatePath})", tlsConfiguration.ClientCertificatePath); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load client certificate from {CertificatePath}", tlsConfiguration.ClientCertificatePath); - } - } - - sslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) => - ValidateServerCertificate(tlsConfiguration, logger, certificate, chain, sslPolicyErrors); - } - - private static bool ValidateServerCertificate( - ClientTlsConfiguration tlsConfiguration, - ILogger logger, - X509Certificate? certificate, - X509Chain? chain, - SslPolicyErrors sslPolicyErrors) - { - if (tlsConfiguration.IgnoreAllCertificateErrors) - { - logger.LogWarning("SECURITY WARNING: Ignoring all certificate validation errors for LmxProxy gRPC connection."); - return true; - } - - if (certificate is null) - { - logger.LogWarning("Server certificate was null."); - return false; - } - - if (!tlsConfiguration.ValidateServerCertificate) - { - logger.LogWarning("SECURITY WARNING: Server certificate validation disabled for LmxProxy gRPC connection."); - return true; - } - - X509Certificate2 certificate2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate); - - if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride)) - { - string dnsName = certificate2.GetNameInfo(X509NameType.DnsName, forIssuer: false); - if (!string.Equals(dnsName, tlsConfiguration.ServerNameOverride, StringComparison.OrdinalIgnoreCase)) - { - logger.LogWarning("Server certificate subject '{Subject}' does not match expected host '{ExpectedHost}'", - dnsName, tlsConfiguration.ServerNameOverride); - return false; - } - } - - using X509Chain validationChain = chain ?? new X509Chain(); - validationChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - validationChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; - - if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerCaCertificatePath) && - File.Exists(tlsConfiguration.ServerCaCertificatePath)) - { - try - { - X509Certificate2 ca = LoadCertificate(tlsConfiguration.ServerCaCertificatePath); - validationChain.ChainPolicy.CustomTrustStore.Add(ca); - validationChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to load CA certificate from {Path}", tlsConfiguration.ServerCaCertificatePath); - } - } - - if (tlsConfiguration.AllowSelfSignedCertificates) - { - validationChain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority; - } - - bool isValid = validationChain.Build(certificate2); - if (isValid) - { - return true; - } - - if (tlsConfiguration.AllowSelfSignedCertificates && - validationChain.ChainStatus.All(status => - status.Status == X509ChainStatusFlags.UntrustedRoot || - status.Status == X509ChainStatusFlags.PartialChain)) - { - logger.LogWarning("Accepting self-signed certificate for {Subject}", certificate2.Subject); - return true; - } - - string statusMessage = string.Join(", ", validationChain.ChainStatus.Select(s => s.Status)); - logger.LogWarning("Server certificate validation failed: {Status}", statusMessage); - return false; - } - - private static X509Certificate2 LoadCertificate(string path) - { - try - { - return X509Certificate2.CreateFromPemFile(path); - } - catch - { - return new X509Certificate2(File.ReadAllBytes(path)); - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs deleted file mode 100644 index 918e53d..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Extension methods for registering LmxProxyClient with dependency injection - /// - public static class ServiceCollectionExtensions - { - /// - /// Adds LmxProxyClient services to the service collection - /// - /// The service collection - /// Application configuration - /// The service collection for chaining - public static IServiceCollection AddLmxProxyClient(this IServiceCollection services, IConfiguration configuration) - { - return services.AddLmxProxyClient(configuration, "LmxProxy"); - } - - /// - /// Adds LmxProxyClient services to the service collection with a specific configuration section - /// - /// The service collection - /// Application configuration - /// Name of the configuration section - /// The service collection for chaining - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, - IConfiguration configuration, - string configurationSection) - { - services.AddSingleton(); - - // Register a singleton client with default configuration - services.AddSingleton(provider => - { - ILmxProxyClientFactory factory = provider.GetRequiredService(); - return factory.CreateClient(configurationSection); - }); - - return services; - } - - /// - /// Adds LmxProxyClient services to the service collection with custom configuration - /// - /// The service collection - /// Action to configure the client builder - /// The service collection for chaining - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, - Action configureClient) - { - services.AddSingleton(); - - // Register a singleton client with custom configuration - services.AddSingleton(provider => - { - ILmxProxyClientFactory factory = provider.GetRequiredService(); - return factory.CreateClient(configureClient); - }); - - return services; - } - - /// - /// Adds LmxProxyClient services to the service collection with scoped lifetime - /// - /// The service collection - /// Application configuration - /// The service collection for chaining - public static IServiceCollection AddScopedLmxProxyClient( - this IServiceCollection services, - IConfiguration configuration) - { - services.AddSingleton(); - - // Register a scoped client - services.AddScoped(provider => - { - ILmxProxyClientFactory factory = provider.GetRequiredService(); - return factory.CreateClient(); - }); - - return services; - } - - /// - /// Adds named LmxProxyClient services to the service collection - /// - /// The service collection - /// Name for the client - /// Action to configure the client builder - /// The service collection for chaining - public static IServiceCollection AddNamedLmxProxyClient( - this IServiceCollection services, - string name, - Action configureClient) - { - services.AddSingleton(); - - // Register a keyed singleton - services.AddKeyedSingleton(name, (provider, _) => - { - ILmxProxyClientFactory factory = provider.GetRequiredService(); - return factory.CreateClient(configureClient); - }); - - return services; - } - } - - /// - /// Configuration options for LmxProxyClient - /// - public class LmxProxyClientOptions - { - /// - /// Gets or sets the host address - /// - public string Host { get; set; } = "localhost"; - - /// - /// Gets or sets the port number - /// - public int Port { get; set; } = 5050; - - /// - /// Gets or sets the API key - /// - public string? ApiKey { get; set; } - - /// - /// Gets or sets the timeout duration - /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Gets or sets whether to use SSL - /// - public bool UseSsl { get; set; } - - /// - /// Gets or sets the certificate path for SSL - /// - public string? CertificatePath { get; set; } - - /// - /// Gets or sets whether to enable metrics - /// - public bool EnableMetrics { get; set; } - - /// - /// Gets or sets the correlation ID header name - /// - public string? CorrelationIdHeader { get; set; } - - /// - /// Gets or sets the retry configuration - /// - public RetryOptions? Retry { get; set; } - } - - /// - /// Retry configuration options - /// - public class RetryOptions - { - /// - /// Gets or sets the maximum number of retry attempts - /// - public int MaxAttempts { get; set; } = 3; - - /// - /// Gets or sets the delay between retries - /// - public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs deleted file mode 100644 index c36f5e6..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client -{ - /// - /// Extension methods for streaming operations with the LmxProxy client - /// - public static class StreamingExtensions - { - /// - /// Reads multiple tag values as an async stream for efficient memory usage with large datasets - /// - /// The LmxProxy client - /// The addresses to read - /// Size of each batch to process - /// Cancellation token - /// An async enumerable of tag values - public static async IAsyncEnumerable> ReadStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - int batchSize = 100, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive"); - - var batch = new List(batchSize); - int errorCount = 0; - const int maxConsecutiveErrors = 3; - - foreach (string address in addresses) - { - batch.Add(address); - - if (batch.Count >= batchSize) - { - bool success = false; - int retries = 0; - const int maxRetries = 2; - - while (!success && retries < maxRetries) - { - IDictionary? results = null; - Exception? lastException = null; - - try - { - results = await client.ReadBatchAsync(batch, cancellationToken); - errorCount = 0; // Reset error count on success - success = true; - } - catch (OperationCanceledException) - { - throw; // Don't retry on cancellation - } - catch (Exception ex) - { - lastException = ex; - retries++; - errorCount++; - - if (errorCount >= maxConsecutiveErrors) - { - throw new InvalidOperationException( - $"Stream reading failed after {maxConsecutiveErrors} consecutive errors", ex); - } - - if (retries >= maxRetries) - { - // Log error and continue with next batch - System.Diagnostics.Debug.WriteLine($"Failed to read batch after {maxRetries} retries: {ex.Message}"); - batch.Clear(); - break; - } - - // Wait before retry with exponential backoff - await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, retries - 1)), cancellationToken); - } - - if (results != null) - { - foreach (KeyValuePair result in results) - { - yield return result; - } - batch.Clear(); - } - } - } - - cancellationToken.ThrowIfCancellationRequested(); - } - - // Process remaining items - if (batch.Count > 0) - { - IDictionary? results = null; - - try - { - results = await client.ReadBatchAsync(batch, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - // Log error for final batch but don't throw to allow partial results - System.Diagnostics.Debug.WriteLine($"Failed to read final batch: {ex.Message}"); - } - - if (results != null) - { - foreach (KeyValuePair result in results) - { - yield return result; - } - } - } - } - - /// - /// Writes multiple tag values as an async stream for efficient memory usage with large datasets - /// - /// The LmxProxy client - /// The values to write as an async enumerable - /// Size of each batch to process - /// Cancellation token - /// The number of values written - public static async Task WriteStreamAsync( - this ILmxProxyClient client, - IAsyncEnumerable> values, - int batchSize = 100, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(values); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive"); - - var batch = new Dictionary(batchSize); - int totalWritten = 0; - - await foreach (KeyValuePair kvp in values.WithCancellation(cancellationToken)) - { - batch[kvp.Key] = kvp.Value; - - if (batch.Count >= batchSize) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - batch.Clear(); - } - } - - // Process remaining items - if (batch.Count > 0) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - } - - return totalWritten; - } - - /// - /// Processes tag values in parallel batches for maximum throughput - /// - /// The LmxProxy client - /// The addresses to read - /// The async function to process each value - /// Maximum number of concurrent operations - /// Cancellation token - public static async Task ProcessInParallelAsync( - this ILmxProxyClient client, - IEnumerable addresses, - Func processor, - int maxDegreeOfParallelism = 4, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - ArgumentNullException.ThrowIfNull(processor); - if (maxDegreeOfParallelism <= 0) - throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism)); - - var semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); - var tasks = new List(); - - await foreach (KeyValuePair kvp in client.ReadStreamAsync(addresses, cancellationToken: cancellationToken)) - { - await semaphore.WaitAsync(cancellationToken); - - var task = Task.Run(async () => - { - try - { - await processor(kvp.Key, kvp.Value); - } - finally - { - semaphore.Release(); - } - }, cancellationToken); - - tasks.Add(task); - } - - await Task.WhenAll(tasks); - } - - /// - /// Subscribes to multiple tags and returns updates as an async stream - /// - /// The LmxProxy client - /// The addresses to subscribe to - /// Poll interval in milliseconds - /// Cancellation token - /// An async enumerable of tag updates - public static async IAsyncEnumerable SubscribeStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - int pollIntervalMs = 1000, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - - var updateChannel = System.Threading.Channels.Channel.CreateUnbounded(); - - // Setup update handler - void OnUpdate(string address, Vtq vtq) - { - updateChannel.Writer.TryWrite(vtq); - } - - ISubscription subscription = await client.SubscribeAsync(addresses, OnUpdate, cancellationToken); - - try - { - await foreach (Vtq update in updateChannel.Reader.ReadAllAsync(cancellationToken)) - { - yield return update; - } - } - finally - { - await subscription.DisposeAsync(); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj deleted file mode 100644 index 5f65bc3..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net10.0 - latest - enable - ZB.MOM.WW.LmxProxy.Client - ZB.MOM.WW.LmxProxy.Client - true - true - gRPC client library for LmxProxy service - AnyCPU - AnyCPU - - - - - - - - - - - - - - diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config deleted file mode 100644 index ee29aaa..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs deleted file mode 100644 index 286e152..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Validates LmxProxy configuration settings on startup. - /// - public static class ConfigurationValidator - { - private static readonly ILogger Logger = Log.ForContext(typeof(ConfigurationValidator)); - - /// - /// Validates the provided configuration and returns a list of validation errors. - /// - /// The configuration to validate. - /// A list of validation error messages. Empty if configuration is valid. - public static List Validate(LmxProxyConfiguration configuration) - { - var errors = new List(); - - if (configuration == null) - { - errors.Add("Configuration is null"); - return errors; - } - - // Validate gRPC port - if (configuration.GrpcPort <= 0 || configuration.GrpcPort > 65535) - { - errors.Add($"Invalid gRPC port: {configuration.GrpcPort}. Must be between 1 and 65535."); - } - - // Validate API key configuration file - if (string.IsNullOrWhiteSpace(configuration.ApiKeyConfigFile)) - { - errors.Add("API key configuration file path is not specified."); - } - - // Validate Connection settings - if (configuration.Connection != null) - { - ValidateConnectionConfiguration(configuration.Connection, errors); - } - else - { - errors.Add("Connection configuration is missing."); - } - - // Validate Subscription settings - if (configuration.Subscription != null) - { - ValidateSubscriptionConfiguration(configuration.Subscription, errors); - } - - // Validate Service Recovery settings - if (configuration.ServiceRecovery != null) - { - ValidateServiceRecoveryConfiguration(configuration.ServiceRecovery, errors); - } - - // Validate TLS settings - if (configuration.Tls != null) - { - if (!configuration.Tls.Validate()) - { - errors.Add("TLS configuration validation failed. Check the logs for details."); - } - } - - return errors; - } - - private static void ValidateConnectionConfiguration(ConnectionConfiguration config, List errors) - { - if (config.MonitorIntervalSeconds <= 0) - { - errors.Add( - $"Invalid monitor interval: {config.MonitorIntervalSeconds} seconds. Must be greater than 0."); - } - - if (config.ConnectionTimeoutSeconds <= 0) - { - errors.Add( - $"Invalid connection timeout: {config.ConnectionTimeoutSeconds} seconds. Must be greater than 0."); - } - - if (config.ReadTimeoutSeconds <= 0) - { - errors.Add($"Invalid read timeout: {config.ReadTimeoutSeconds} seconds. Must be greater than 0."); - } - - if (config.WriteTimeoutSeconds <= 0) - { - errors.Add($"Invalid write timeout: {config.WriteTimeoutSeconds} seconds. Must be greater than 0."); - } - - if (config.MaxConcurrentOperations.HasValue && config.MaxConcurrentOperations.Value <= 0) - { - errors.Add( - $"Invalid max concurrent operations: {config.MaxConcurrentOperations}. Must be greater than 0."); - } - - // Validate node and galaxy names if provided - if (!string.IsNullOrWhiteSpace(config.NodeName) && config.NodeName?.Length > 255) - { - errors.Add($"Node name is too long: {config.NodeName.Length} characters. Maximum is 255."); - } - - if (!string.IsNullOrWhiteSpace(config.GalaxyName) && config.GalaxyName?.Length > 255) - { - errors.Add($"Galaxy name is too long: {config.GalaxyName.Length} characters. Maximum is 255."); - } - } - - private static void ValidateSubscriptionConfiguration(SubscriptionConfiguration config, List errors) - { - if (config.ChannelCapacity <= 0) - { - errors.Add($"Invalid channel capacity: {config.ChannelCapacity}. Must be greater than 0."); - } - - if (config.ChannelCapacity > 100000) - { - errors.Add($"Channel capacity too large: {config.ChannelCapacity}. Maximum recommended is 100000."); - } - - string[] validChannelModes = { "DropOldest", "DropNewest", "Wait" }; - if (!validChannelModes.Contains(config.ChannelFullMode)) - { - errors.Add( - $"Invalid channel full mode: {config.ChannelFullMode}. Valid values are: {string.Join(", ", validChannelModes)}"); - } - } - - private static void ValidateServiceRecoveryConfiguration(ServiceRecoveryConfiguration config, - List errors) - { - if (config.FirstFailureDelayMinutes < 0) - { - errors.Add( - $"Invalid first failure delay: {config.FirstFailureDelayMinutes} minutes. Must be 0 or greater."); - } - - if (config.SecondFailureDelayMinutes < 0) - { - errors.Add( - $"Invalid second failure delay: {config.SecondFailureDelayMinutes} minutes. Must be 0 or greater."); - } - - if (config.SubsequentFailureDelayMinutes < 0) - { - errors.Add( - $"Invalid subsequent failure delay: {config.SubsequentFailureDelayMinutes} minutes. Must be 0 or greater."); - } - - if (config.ResetPeriodDays <= 0) - { - errors.Add($"Invalid reset period: {config.ResetPeriodDays} days. Must be greater than 0."); - } - } - - /// - /// Logs validation results and returns whether the configuration is valid. - /// - /// The configuration to validate. - /// True if configuration is valid; otherwise, false. - public static bool ValidateAndLog(LmxProxyConfiguration configuration) - { - List errors = Validate(configuration); - - if (errors.Any()) - { - Logger.Error("Configuration validation failed with {ErrorCount} errors:", errors.Count); - foreach (string? error in errors) - { - Logger.Error(" - {ValidationError}", error); - } - - return false; - } - - Logger.Information("Configuration validation successful"); - return true; - } - - /// - /// Throws an exception if the configuration is invalid. - /// - /// The configuration to validate. - /// Thrown when configuration is invalid. - public static void ValidateOrThrow(LmxProxyConfiguration configuration) - { - List errors = Validate(configuration); - - if (errors.Any()) - { - string message = $"Configuration validation failed with {errors.Count} error(s):\n" + - string.Join("\n", errors.Select(e => $" - {e}")); - throw new InvalidOperationException(message); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs deleted file mode 100644 index cebc9d1..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs +++ /dev/null @@ -1,110 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Configuration settings for LmxProxy service - /// - public class LmxProxyConfiguration - { - /// - /// gRPC server port - /// - public int GrpcPort { get; set; } = 50051; - - /// - /// Subscription management settings - /// - public SubscriptionConfiguration Subscription { get; set; } = new(); - - /// - /// Windows service recovery settings - /// - public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new(); - - /// - /// API key configuration file path - /// - public string ApiKeyConfigFile { get; set; } = "apikeys.json"; - - /// - /// MxAccess connection settings - /// - public ConnectionConfiguration Connection { get; set; } = new(); - - /// - /// TLS/SSL configuration for secure gRPC communication - /// - public TlsConfiguration Tls { get; set; } = new(); - - /// - /// Web server configuration for status display - /// - public WebServerConfiguration WebServer { get; set; } = new(); - } - - /// - /// Configuration for MxAccess connection monitoring and reconnection - /// - public class ConnectionConfiguration - { - /// - /// Interval in seconds between connection health checks - /// - public int MonitorIntervalSeconds { get; set; } = 5; - - /// - /// Timeout in seconds for initial connection attempts - /// - public int ConnectionTimeoutSeconds { get; set; } = 30; - - /// - /// Whether to automatically reconnect when connection is lost - /// - public bool AutoReconnect { get; set; } = true; - - /// - /// Timeout in seconds for read operations - /// - public int ReadTimeoutSeconds { get; set; } = 5; - - /// - /// Timeout in seconds for write operations - /// - public int WriteTimeoutSeconds { get; set; } = 5; - - /// - /// Maximum number of concurrent read/write operations allowed - /// - public int? MaxConcurrentOperations { get; set; } = 10; - - /// - /// Name of the node to connect to (optional) - /// - public string? NodeName { get; set; } - - /// - /// Name of the galaxy to connect to (optional) - /// - public string? GalaxyName { get; set; } - } - - /// - /// Configuration for web server that displays status information - /// - public class WebServerConfiguration - { - /// - /// Whether the web server is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// Port number for the web server - /// - public int Port { get; set; } = 8080; - - /// - /// Prefix URL for the web server (default: http://+:{Port}/) - /// - public string? Prefix { get; set; } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs deleted file mode 100644 index 5ca4ded..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Configuration for Windows service recovery - /// - public class ServiceRecoveryConfiguration - { - /// - /// Minutes to wait before restart on first failure - /// - public int FirstFailureDelayMinutes { get; set; } = 1; - - /// - /// Minutes to wait before restart on second failure - /// - public int SecondFailureDelayMinutes { get; set; } = 5; - - /// - /// Minutes to wait before restart on subsequent failures - /// - public int SubsequentFailureDelayMinutes { get; set; } = 10; - - /// - /// Days before resetting the failure count - /// - public int ResetPeriodDays { get; set; } = 1; - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs deleted file mode 100644 index 1637c4a..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Configuration for subscription management - /// - public class SubscriptionConfiguration - { - /// - /// Buffer size for each client's channel (number of messages) - /// - public int ChannelCapacity { get; set; } = 1000; - - /// - /// Strategy when channel buffer is full: "DropOldest", "DropNewest", or "Wait" - /// - public string ChannelFullMode { get; set; } = "DropOldest"; - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs deleted file mode 100644 index 56b278e..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.IO; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Configuration for TLS/SSL settings for secure gRPC communication - /// - public class TlsConfiguration - { - /// - /// Gets or sets whether TLS is enabled for gRPC communication - /// - public bool Enabled { get; set; } = false; - - /// - /// Gets or sets the path to the server certificate file (.pem or .crt) - /// - public string ServerCertificatePath { get; set; } = string.Empty; - - /// - /// Gets or sets the path to the server private key file (.key) - /// - public string ServerKeyPath { get; set; } = string.Empty; - - /// - /// Gets or sets the path to the certificate authority file for client certificate validation (optional) - /// - public string? ClientCaCertificatePath { get; set; } - - /// - /// Gets or sets whether to require client certificates for mutual TLS - /// - public bool RequireClientCertificate { get; set; } = false; - - /// - /// Gets or sets whether to check certificate revocation - /// - public bool CheckCertificateRevocation { get; set; } = true; - - /// - /// Validates the TLS configuration - /// - /// True if configuration is valid, false otherwise - public bool Validate() - { - if (!Enabled) - { - return true; // No validation needed if TLS is disabled - } - - if (string.IsNullOrWhiteSpace(ServerCertificatePath)) - { - Log.Error("TLS is enabled but ServerCertificatePath is not configured"); - return false; - } - - if (string.IsNullOrWhiteSpace(ServerKeyPath)) - { - Log.Error("TLS is enabled but ServerKeyPath is not configured"); - return false; - } - - if (!File.Exists(ServerCertificatePath)) - { - Log.Warning("Server certificate file not found: {Path} - will be auto-generated on startup", - ServerCertificatePath); - } - - if (!File.Exists(ServerKeyPath)) - { - Log.Warning("Server key file not found: {Path} - will be auto-generated on startup", ServerKeyPath); - } - - if (RequireClientCertificate && string.IsNullOrWhiteSpace(ClientCaCertificatePath)) - { - Log.Error("Client certificate is required but ClientCaCertificatePath is not configured"); - return false; - } - - if (!string.IsNullOrWhiteSpace(ClientCaCertificatePath) && !File.Exists(ClientCaCertificatePath)) - { - Log.Warning("Client CA certificate file not found: {Path} - will be auto-generated on startup", - ClientCaCertificatePath); - } - - return true; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs deleted file mode 100644 index 2d060f7..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ClientStats.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Per-client subscription statistics. - /// - public class ClientStats - { - /// - /// Gets or sets the number of tags the client is subscribed to. - /// - public int SubscribedTags { get; set; } - - /// - /// Gets or sets the number of delivered messages. - /// - public long DeliveredMessages { get; set; } - - /// - /// Gets or sets the number of dropped messages. - /// - public long DroppedMessages { get; set; } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs deleted file mode 100644 index ed1e37e..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Represents the state of a SCADA client connection. - /// - public enum ConnectionState - { - /// - /// The client is disconnected. - /// - Disconnected, - - /// - /// The client is in the process of connecting. - /// - Connecting, - - /// - /// The client is connected. - /// - Connected, - - /// - /// The client is in the process of disconnecting. - /// - Disconnecting, - - /// - /// The client encountered an error. - /// - Error, - - /// - /// The client is reconnecting after a connection loss. - /// - Reconnecting - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs deleted file mode 100644 index 0549b1b..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Event arguments for SCADA client connection state changes. - /// - public class ConnectionStateChangedEventArgs : EventArgs - { - /// - /// Initializes a new instance of the class. - /// - /// The previous connection state. - /// The current connection state. - /// Optional message providing additional information about the state change. - public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, - string? message = null) - { - PreviousState = previousState; - CurrentState = currentState; - Timestamp = DateTime.UtcNow; - Message = message; - } - - /// - /// Gets the previous connection state. - /// - public ConnectionState PreviousState { get; } - - /// - /// Gets the current connection state. - /// - public ConnectionState CurrentState { get; } - - /// - /// Gets the timestamp when the state change occurred. - /// - public DateTime Timestamp { get; } - - /// - /// Gets additional information about the state change, such as error messages. - /// - public string? Message { get; } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs deleted file mode 100644 index 1d16dc7..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Interface for SCADA system clients. - /// - public interface IScadaClient : IAsyncDisposable - { - /// - /// Gets the connection status. - /// - bool IsConnected { get; } - - /// - /// Gets the current connection state. - /// - ConnectionState ConnectionState { get; } - - /// - /// Occurs when the connection state changes. - /// - event EventHandler ConnectionStateChanged; - - /// - /// Connects to the SCADA system. - /// - /// Cancellation token. - Task ConnectAsync(CancellationToken ct = default); - - /// - /// Disconnects from the SCADA system. - /// - /// Cancellation token. - Task DisconnectAsync(CancellationToken ct = default); - - /// - /// Reads a single tag value from the SCADA system. - /// - /// The tag address. - /// Cancellation token. - /// The value, timestamp, and quality. - Task ReadAsync(string address, CancellationToken ct = default); - - /// - /// Reads multiple tag values from the SCADA system. - /// - /// The tag addresses. - /// Cancellation token. - /// Dictionary of address to VTQ values. - Task> - ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); - - /// - /// Writes a single tag value to the SCADA system. - /// - /// The tag address. - /// The value to write. - /// Cancellation token. - Task WriteAsync(string address, object value, CancellationToken ct = default); - - /// - /// Writes multiple tag values to the SCADA system. - /// - /// Dictionary of address to value. - /// Cancellation token. - Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); - - /// - /// Writes a batch of tag values and a flag tag, then waits for a response tag to - /// equal the expected value. - /// - /// The regular tag values to write. - /// The address of the flag tag to write. - /// The value to write to the flag tag. - /// The address of the response tag to monitor. - /// The expected value of the response tag. - /// Cancellation token controlling the wait. - /// - /// true if the response value was observed before cancellation; - /// otherwise false. - /// - Task WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagAddress, - object flagValue, - string responseAddress, - object responseValue, - CancellationToken ct = default); - - /// - /// Subscribes to value changes for specified addresses. - /// - /// The tag addresses to monitor. - /// Callback for value changes. - /// Cancellation token. - /// Subscription handle for unsubscribing. - Task SubscribeAsync(IEnumerable addresses, Action callback, - CancellationToken ct = default); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs deleted file mode 100644 index 8cd7715..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs +++ /dev/null @@ -1,124 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// OPC quality codes mapped to domain-level values. - /// The byte value matches the low-order byte of the OPC UA StatusCode, - /// so it can be persisted or round-tripped without translation. - /// - public enum Quality : byte - { - // ─────────────── Bad family (0-31) ─────────────── - /// 0x00 – Bad [Non-Specific] - Bad = 0, - - /// 0x01 – Unknown quality value - Unknown = 1, - - /// 0x04 – Bad [Configuration Error] - Bad_ConfigError = 4, - - /// 0x08 – Bad [Not Connected] - Bad_NotConnected = 8, - - /// 0x0C – Bad [Device Failure] - Bad_DeviceFailure = 12, - - /// 0x10 – Bad [Sensor Failure] - Bad_SensorFailure = 16, - - /// 0x14 – Bad [Last Known Value] - Bad_LastKnownValue = 20, - - /// 0x18 – Bad [Communication Failure] - Bad_CommFailure = 24, - - /// 0x1C – Bad [Out of Service] - Bad_OutOfService = 28, - - // ──────────── Uncertain family (64-95) ─────────── - /// 0x40 – Uncertain [Non-Specific] - Uncertain = 64, - - /// 0x41 – Uncertain [Non-Specific] (Low Limited) - Uncertain_LowLimited = 65, - - /// 0x42 – Uncertain [Non-Specific] (High Limited) - Uncertain_HighLimited = 66, - - /// 0x43 – Uncertain [Non-Specific] (Constant) - Uncertain_Constant = 67, - - /// 0x44 – Uncertain [Last Usable] - Uncertain_LastUsable = 68, - - /// 0x45 – Uncertain [Last Usable] (Low Limited) - Uncertain_LastUsable_LL = 69, - - /// 0x46 – Uncertain [Last Usable] (High Limited) - Uncertain_LastUsable_HL = 70, - - /// 0x47 – Uncertain [Last Usable] (Constant) - Uncertain_LastUsable_Cnst = 71, - - /// 0x50 – Uncertain [Sensor Not Accurate] - Uncertain_SensorNotAcc = 80, - - /// 0x51 – Uncertain [Sensor Not Accurate] (Low Limited) - Uncertain_SensorNotAcc_LL = 81, - - /// 0x52 – Uncertain [Sensor Not Accurate] (High Limited) - Uncertain_SensorNotAcc_HL = 82, - - /// 0x53 – Uncertain [Sensor Not Accurate] (Constant) - Uncertain_SensorNotAcc_C = 83, - - /// 0x54 – Uncertain [EU Exceeded] - Uncertain_EuExceeded = 84, - - /// 0x55 – Uncertain [EU Exceeded] (Low Limited) - Uncertain_EuExceeded_LL = 85, - - /// 0x56 – Uncertain [EU Exceeded] (High Limited) - Uncertain_EuExceeded_HL = 86, - - /// 0x57 – Uncertain [EU Exceeded] (Constant) - Uncertain_EuExceeded_C = 87, - - /// 0x58 – Uncertain [Sub-Normal] - Uncertain_SubNormal = 88, - - /// 0x59 – Uncertain [Sub-Normal] (Low Limited) - Uncertain_SubNormal_LL = 89, - - /// 0x5A – Uncertain [Sub-Normal] (High Limited) - Uncertain_SubNormal_HL = 90, - - /// 0x5B – Uncertain [Sub-Normal] (Constant) - Uncertain_SubNormal_C = 91, - - // ─────────────── Good family (192-219) ──────────── - /// 0xC0 – Good [Non-Specific] - Good = 192, - - /// 0xC1 – Good [Non-Specific] (Low Limited) - Good_LowLimited = 193, - - /// 0xC2 – Good [Non-Specific] (High Limited) - Good_HighLimited = 194, - - /// 0xC3 – Good [Non-Specific] (Constant) - Good_Constant = 195, - - /// 0xD8 – Good [Local Override] - Good_LocalOverride = 216, - - /// 0xD9 – Good [Local Override] (Low Limited) - Good_LocalOverride_LL = 217, - - /// 0xDA – Good [Local Override] (High Limited) - Good_LocalOverride_HL = 218, - - /// 0xDB – Good [Local Override] (Constant) - Good_LocalOverride_C = 219 - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs deleted file mode 100644 index bea1675..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Subscription statistics for all clients and tags. - /// - public class SubscriptionStats - { - /// - /// Gets or sets the total number of clients. - /// - public int TotalClients { get; set; } - - /// - /// Gets or sets the total number of tags. - /// - public int TotalTags { get; set; } - - /// - /// Gets or sets the mapping of tag addresses to client counts. - /// - public Dictionary TagClientCounts { get; set; } = new(); - - /// - /// Gets or sets the mapping of client IDs to their statistics. - /// - public Dictionary ClientStats { get; set; } = new(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs deleted file mode 100644 index 7249908..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Value, Timestamp, and Quality structure for SCADA data. - /// - public readonly struct Vtq : IEquatable - { - /// - /// Gets the value. - /// - public object? Value { get; } - - /// - /// Gets the timestamp when the value was read. - /// - public DateTime Timestamp { get; } - - /// - /// Gets the quality of the value. - /// - public Quality Quality { get; } - - /// - /// Initializes a new instance of the struct. - /// - /// The value. - /// The timestamp when the value was read. - /// The quality of the value. - public Vtq(object? value, DateTime timestamp, Quality quality) - { - Value = value; - Timestamp = timestamp; - Quality = quality; - } - - /// - /// Creates a new instance with the specified value and quality, using the current UTC timestamp. - /// - /// The value. - /// The quality of the value. - /// A new instance. - public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality); - - /// - /// Creates a new instance with the specified value, timestamp, and quality. - /// - /// The value. - /// The timestamp when the value was read. - /// The quality of the value. - /// A new instance. - public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - - /// - /// Creates a instance with good quality and the current UTC timestamp. - /// - /// The value. - /// A new instance with good quality. - public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - - /// - /// Creates a instance with bad quality and the current UTC timestamp. - /// - /// The value. Optional. - /// A new instance with bad quality. - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - - /// - /// Creates a instance with uncertain quality and the current UTC timestamp. - /// - /// The value. - /// A new instance with uncertain quality. - public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - - /// - /// Determines whether the specified is equal to the current . - /// - /// The to compare with the current . - /// true if the specified is equal to the current ; otherwise, false. - public bool Equals(Vtq other) => - Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; - - /// - /// Determines whether the specified object is equal to the current . - /// - /// The object to compare with the current . - /// true if the specified object is equal to the current ; otherwise, false. - public override bool Equals(object obj) => obj is Vtq other && Equals(other); - - /// - /// Returns the hash code for this instance. - /// - /// A 32-bit signed integer hash code. - public override int GetHashCode() - { - unchecked - { - int hashCode = Value != null ? Value.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); - hashCode = (hashCode * 397) ^ (int)Quality; - return hashCode; - } - } - - /// - /// Returns a string that represents the current object. - /// - /// A string that represents the current object. - public override string ToString() => - $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; - - /// - /// Determines whether two specified instances of are equal. - /// - /// The first to compare. - /// The second to compare. - /// true if left and right are equal; otherwise, false. - public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); - - /// - /// Determines whether two specified instances of are not equal. - /// - /// The first to compare. - /// The second to compare. - /// true if left and right are not equal; otherwise, false. - public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto deleted file mode 100644 index 145b684..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto +++ /dev/null @@ -1,166 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc"; - -package scada; - -// The SCADA service definition -service ScadaService { - // Connection management - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - - // Read operations - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - - // Write operations - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - - // Subscription operations (server streaming) - now streams VtqMessage directly - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - - // Authentication - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} - -// === CONNECTION MESSAGES === - -message ConnectRequest { - string client_id = 1; - string api_key = 2; -} - -message ConnectResponse { - bool success = 1; - string message = 2; - string session_id = 3; -} - -message DisconnectRequest { - string session_id = 1; -} - -message DisconnectResponse { - bool success = 1; - string message = 2; -} - -message GetConnectionStateRequest { - string session_id = 1; -} - -message GetConnectionStateResponse { - bool is_connected = 1; - string client_id = 2; - int64 connected_since_utc_ticks = 3; -} - -// === VTQ MESSAGE === - -message VtqMessage { - string tag = 1; - string value = 2; - int64 timestamp_utc_ticks = 3; - string quality = 4; // "Good", "Uncertain", "Bad" -} - -// === READ MESSAGES === - -message ReadRequest { - string session_id = 1; - string tag = 2; -} - -message ReadResponse { - bool success = 1; - string message = 2; - VtqMessage vtq = 3; -} - -message ReadBatchRequest { - string session_id = 1; - repeated string tags = 2; -} - -message ReadBatchResponse { - bool success = 1; - string message = 2; - repeated VtqMessage vtqs = 3; -} - -// === WRITE MESSAGES === - -message WriteRequest { - string session_id = 1; - string tag = 2; - string value = 3; -} - -message WriteResponse { - bool success = 1; - string message = 2; -} - -message WriteItem { - string tag = 1; - string value = 2; -} - -message WriteResult { - string tag = 1; - bool success = 2; - string message = 3; -} - -message WriteBatchRequest { - string session_id = 1; - repeated WriteItem items = 2; -} - -message WriteBatchResponse { - bool success = 1; - string message = 2; - repeated WriteResult results = 3; -} - -message WriteBatchAndWaitRequest { - string session_id = 1; - repeated WriteItem items = 2; - string flag_tag = 3; - string flag_value = 4; - int32 timeout_ms = 5; - int32 poll_interval_ms = 6; -} - -message WriteBatchAndWaitResponse { - bool success = 1; - string message = 2; - repeated WriteResult write_results = 3; - bool flag_reached = 4; - int32 elapsed_ms = 5; -} - -// === SUBSCRIPTION MESSAGES === - -message SubscribeRequest { - string session_id = 1; - repeated string tags = 2; - int32 sampling_ms = 3; -} - -// Note: Subscribe RPC now streams VtqMessage directly (defined above) - -// === AUTHENTICATION MESSAGES === - -message CheckApiKeyRequest { - string api_key = 1; -} - -message CheckApiKeyResponse { - bool is_valid = 1; - string message = 2; -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs deleted file mode 100644 index 8987251..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs +++ /dev/null @@ -1,804 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using Grpc.Core; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Security; -using ZB.MOM.WW.LmxProxy.Host.Services; -using ZB.MOM.WW.LmxProxy.Host.Grpc; - -namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services -{ - /// - /// gRPC service implementation for SCADA operations. - /// Provides methods for connecting, reading, writing, batch operations, and subscriptions. - /// - public class ScadaGrpcService : ScadaService.ScadaServiceBase - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly PerformanceMetrics _performanceMetrics; - private readonly IScadaClient _scadaClient; - private readonly SessionManager _sessionManager; - private readonly SubscriptionManager _subscriptionManager; - - /// - /// Initializes a new instance of the class. - /// - /// The SCADA client instance. - /// The subscription manager instance. - /// The session manager instance. - /// Optional performance metrics service for tracking operations. - /// Thrown if any required argument is null. - public ScadaGrpcService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - SessionManager sessionManager, - PerformanceMetrics performanceMetrics = null) - { - _scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient)); - _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); - _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); - _performanceMetrics = performanceMetrics; - } - - #region Connection Management - - /// - /// Creates a new session for a client. - /// The MxAccess connection is managed separately at server startup. - /// - /// The connection request with client ID and API key. - /// The gRPC server call context. - /// A with session ID. - public override Task Connect(ConnectRequest request, ServerCallContext context) - { - try - { - Logger.Information("Connect request from {Peer} - ClientId: {ClientId}", - context.Peer, request.ClientId); - - // Validate that MxAccess is connected - if (!_scadaClient.IsConnected) - { - return Task.FromResult(new ConnectResponse - { - Success = false, - Message = "SCADA server is not connected to MxAccess", - SessionId = string.Empty - }); - } - - // Create a new session - var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey); - - return Task.FromResult(new ConnectResponse - { - Success = true, - Message = "Session created successfully", - SessionId = sessionId - }); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to create session for client {ClientId}", request.ClientId); - return Task.FromResult(new ConnectResponse - { - Success = false, - Message = ex.Message, - SessionId = string.Empty - }); - } - } - - /// - /// Terminates a client session. - /// - /// The disconnect request with session ID. - /// The gRPC server call context. - /// A indicating success or failure. - public override Task Disconnect(DisconnectRequest request, ServerCallContext context) - { - try - { - Logger.Information("Disconnect request from {Peer} - SessionId: {SessionId}", - context.Peer, request.SessionId); - - var terminated = _sessionManager.TerminateSession(request.SessionId); - - return Task.FromResult(new DisconnectResponse - { - Success = terminated, - Message = terminated ? "Session terminated successfully" : "Session not found" - }); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to disconnect session {SessionId}", request.SessionId); - return Task.FromResult(new DisconnectResponse - { - Success = false, - Message = ex.Message - }); - } - } - - /// - /// Gets the connection state for a session. - /// - /// The connection state request with session ID. - /// The gRPC server call context. - /// A with connection details. - public override Task GetConnectionState(GetConnectionStateRequest request, - ServerCallContext context) - { - var session = _sessionManager.GetSession(request.SessionId); - - if (session == null) - { - return Task.FromResult(new GetConnectionStateResponse - { - IsConnected = false, - ClientId = string.Empty, - ConnectedSinceUtcTicks = 0 - }); - } - - return Task.FromResult(new GetConnectionStateResponse - { - IsConnected = _scadaClient.IsConnected, - ClientId = session.ClientId, - ConnectedSinceUtcTicks = session.ConnectedSinceUtcTicks - }); - } - - #endregion - - #region Read Operations - - /// - /// Reads a single tag value from the SCADA system. - /// - /// The read request with session ID and tag. - /// The gRPC server call context. - /// A with the VTQ data. - public override async Task Read(ReadRequest request, ServerCallContext context) - { - using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Read")) - { - try - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new ReadResponse - { - Success = false, - Message = "Invalid session ID", - Vtq = CreateBadVtqMessage(request.Tag) - }; - } - - Logger.Debug("Read request from {Peer} for {Tag}", context.Peer, request.Tag); - - Vtq vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken); - - scope?.SetSuccess(true); - return new ReadResponse - { - Success = true, - Message = string.Empty, - Vtq = ConvertToVtqMessage(request.Tag, vtq) - }; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to read {Tag}", request.Tag); - scope?.SetSuccess(false); - return new ReadResponse - { - Success = false, - Message = ex.Message, - Vtq = CreateBadVtqMessage(request.Tag) - }; - } - } - } - - /// - /// Reads multiple tag values from the SCADA system. - /// - /// The batch read request with session ID and tags. - /// The gRPC server call context. - /// A with VTQ data for each tag. - public override async Task ReadBatch(ReadBatchRequest request, ServerCallContext context) - { - using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("ReadBatch")) - { - try - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - var badResponse = new ReadBatchResponse - { - Success = false, - Message = "Invalid session ID" - }; - foreach (var tag in request.Tags) - { - badResponse.Vtqs.Add(CreateBadVtqMessage(tag)); - } - return badResponse; - } - - Logger.Debug("ReadBatch request from {Peer} for {Count} tags", context.Peer, request.Tags.Count); - - IReadOnlyDictionary results = - await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken); - - var response = new ReadBatchResponse - { - Success = true, - Message = string.Empty - }; - - // Return results in the same order as the request tags - foreach (var tag in request.Tags) - { - if (results.TryGetValue(tag, out Vtq vtq)) - { - response.Vtqs.Add(ConvertToVtqMessage(tag, vtq)); - } - else - { - response.Vtqs.Add(CreateBadVtqMessage(tag)); - } - } - - scope?.SetSuccess(true); - return response; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to read batch"); - scope?.SetSuccess(false); - - var response = new ReadBatchResponse - { - Success = false, - Message = ex.Message - }; - - foreach (var tag in request.Tags) - { - response.Vtqs.Add(CreateBadVtqMessage(tag)); - } - - return response; - } - } - } - - #endregion - - #region Write Operations - - /// - /// Writes a single tag value to the SCADA system. - /// - /// The write request with session ID, tag, and value. - /// The gRPC server call context. - /// A indicating success or failure. - public override async Task Write(WriteRequest request, ServerCallContext context) - { - using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Write")) - { - try - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new WriteResponse - { - Success = false, - Message = "Invalid session ID" - }; - } - - Logger.Debug("Write request from {Peer} for {Tag}", context.Peer, request.Tag); - - // Parse the string value to an appropriate type - var value = ParseValue(request.Value); - - await _scadaClient.WriteAsync(request.Tag, value, context.CancellationToken); - - scope?.SetSuccess(true); - return new WriteResponse - { - Success = true, - Message = string.Empty - }; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to write to {Tag}", request.Tag); - scope?.SetSuccess(false); - return new WriteResponse - { - Success = false, - Message = ex.Message - }; - } - } - } - - /// - /// Writes multiple tag values to the SCADA system. - /// - /// The batch write request with session ID and items. - /// The gRPC server call context. - /// A with results for each tag. - public override async Task WriteBatch(WriteBatchRequest request, ServerCallContext context) - { - using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("WriteBatch")) - { - try - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - var badResponse = new WriteBatchResponse - { - Success = false, - Message = "Invalid session ID" - }; - foreach (var item in request.Items) - { - badResponse.Results.Add(new WriteResult - { - Tag = item.Tag, - Success = false, - Message = "Invalid session ID" - }); - } - return badResponse; - } - - Logger.Debug("WriteBatch request from {Peer} for {Count} items", context.Peer, request.Items.Count); - - var values = new Dictionary(); - foreach (var item in request.Items) - { - values[item.Tag] = ParseValue(item.Value); - } - - await _scadaClient.WriteBatchAsync(values, context.CancellationToken); - - scope?.SetSuccess(true); - - var response = new WriteBatchResponse - { - Success = true, - Message = string.Empty - }; - - foreach (var item in request.Items) - { - response.Results.Add(new WriteResult - { - Tag = item.Tag, - Success = true, - Message = string.Empty - }); - } - - return response; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to write batch"); - scope?.SetSuccess(false); - - var response = new WriteBatchResponse - { - Success = false, - Message = ex.Message - }; - - foreach (var item in request.Items) - { - response.Results.Add(new WriteResult - { - Tag = item.Tag, - Success = false, - Message = ex.Message - }); - } - - return response; - } - } - } - - /// - /// Writes a batch of tag values and waits for a flag tag to reach a specific value. - /// - /// The batch write and wait request. - /// The gRPC server call context. - /// A with results and flag status. - public override async Task WriteBatchAndWait(WriteBatchAndWaitRequest request, - ServerCallContext context) - { - var startTime = DateTime.UtcNow; - - try - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - var badResponse = new WriteBatchAndWaitResponse - { - Success = false, - Message = "Invalid session ID", - FlagReached = false, - ElapsedMs = 0 - }; - foreach (var item in request.Items) - { - badResponse.WriteResults.Add(new WriteResult - { - Tag = item.Tag, - Success = false, - Message = "Invalid session ID" - }); - } - return badResponse; - } - - Logger.Debug("WriteBatchAndWait request from {Peer}", context.Peer); - - var values = new Dictionary(); - foreach (var item in request.Items) - { - values[item.Tag] = ParseValue(item.Value); - } - - var flagValue = ParseValue(request.FlagValue); - var pollInterval = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); - cts.CancelAfter(TimeSpan.FromMilliseconds(request.TimeoutMs)); - - // Write the batch first - await _scadaClient.WriteBatchAsync(values, cts.Token); - - // Poll for the flag value - var flagReached = false; - while (!cts.Token.IsCancellationRequested) - { - try - { - var flagVtq = await _scadaClient.ReadAsync(request.FlagTag, cts.Token); - if (flagVtq.Value != null && AreValuesEqual(flagVtq.Value, flagValue)) - { - flagReached = true; - break; - } - - await Task.Delay(pollInterval, cts.Token); - } - catch (OperationCanceledException) - { - break; - } - } - - var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; - - var response = new WriteBatchAndWaitResponse - { - Success = true, - Message = string.Empty, - FlagReached = flagReached, - ElapsedMs = elapsedMs - }; - - foreach (var item in request.Items) - { - response.WriteResults.Add(new WriteResult - { - Tag = item.Tag, - Success = true, - Message = string.Empty - }); - } - - return response; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to write batch and wait"); - - var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; - - var response = new WriteBatchAndWaitResponse - { - Success = false, - Message = ex.Message, - FlagReached = false, - ElapsedMs = elapsedMs - }; - - foreach (var item in request.Items) - { - response.WriteResults.Add(new WriteResult - { - Tag = item.Tag, - Success = false, - Message = ex.Message - }); - } - - return response; - } - } - - #endregion - - #region Subscription Operations - - /// - /// Subscribes to value changes for specified tags and streams updates to the client. - /// - /// The subscribe request with session ID and tags. - /// The server stream writer for VTQ updates. - /// The gRPC server call context. - /// A task representing the asynchronous operation. - public override async Task Subscribe(SubscribeRequest request, - IServerStreamWriter responseStream, ServerCallContext context) - { - // Validate session - if (!_sessionManager.ValidateSession(request.SessionId)) - { - Logger.Warning("Subscribe failed: Invalid session ID {SessionId}", request.SessionId); - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session ID")); - } - - var clientId = Guid.NewGuid().ToString(); - - try - { - Logger.Information("Subscribe request from {Peer} with client ID {ClientId} for {Count} tags", - context.Peer, clientId, request.Tags.Count); - - Channel<(string address, Vtq vtq)> channel = await _subscriptionManager.SubscribeAsync( - clientId, - request.Tags, - context.CancellationToken); - - // Stream updates to the client until cancelled - while (!context.CancellationToken.IsCancellationRequested) - { - try - { - while (await channel.Reader.WaitToReadAsync(context.CancellationToken)) - { - if (channel.Reader.TryRead(out (string address, Vtq vtq) item)) - { - var vtqMessage = ConvertToVtqMessage(item.address, item.vtq); - await responseStream.WriteAsync(vtqMessage); - } - } - } - catch (OperationCanceledException) - { - break; - } - } - } - catch (OperationCanceledException) - { - Logger.Information("Subscription cancelled for client {ClientId}", clientId); - } - catch (Exception ex) - { - Logger.Error(ex, "Error in subscription for client {ClientId}", clientId); - throw; - } - finally - { - _subscriptionManager.UnsubscribeClient(clientId); - } - } - - #endregion - - #region Authentication - - /// - /// Checks the validity of an API key. - /// - /// The API key check request. - /// The gRPC server call context. - /// A with validity and details. - public override Task CheckApiKey(CheckApiKeyRequest request, ServerCallContext context) - { - var response = new CheckApiKeyResponse - { - IsValid = false, - Message = "API key validation failed" - }; - - // Check if API key was validated by interceptor - if (context.UserState.TryGetValue("ApiKey", out object apiKeyObj) && apiKeyObj is ApiKey apiKey) - { - response.IsValid = apiKey.IsValid(); - response.Message = apiKey.IsValid() - ? $"API key is valid (Role: {apiKey.Role})" - : "API key is disabled"; - - Logger.Information("API key check - Valid: {IsValid}, Role: {Role}", - response.IsValid, apiKey.Role); - } - else - { - Logger.Warning("API key check failed - no API key in context"); - } - - return Task.FromResult(response); - } - - #endregion - - #region Value Conversion Helpers - - /// - /// Converts a domain to a gRPC . - /// - private static VtqMessage ConvertToVtqMessage(string tag, Vtq vtq) - { - return new VtqMessage - { - Tag = tag, - Value = ConvertValueToString(vtq.Value), - TimestampUtcTicks = vtq.Timestamp.Ticks, - Quality = ConvertQualityToString(vtq.Quality) - }; - } - - /// - /// Creates a bad quality VTQ message for error cases. - /// - private static VtqMessage CreateBadVtqMessage(string tag) - { - return new VtqMessage - { - Tag = tag, - Value = string.Empty, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = "Bad" - }; - } - - /// - /// Converts a value to its string representation. - /// - private static string ConvertValueToString(object value) - { - if (value == null) - { - return string.Empty; - } - - return value switch - { - bool b => b.ToString().ToLowerInvariant(), - DateTime dt => dt.ToUniversalTime().ToString("O"), - DateTimeOffset dto => dto.ToString("O"), - float f => f.ToString(CultureInfo.InvariantCulture), - double d => d.ToString(CultureInfo.InvariantCulture), - decimal dec => dec.ToString(CultureInfo.InvariantCulture), - Array => JsonSerializer.Serialize(value, value.GetType()), - _ => value.ToString() ?? string.Empty - }; - } - - /// - /// Converts a domain quality value to a string. - /// - private static string ConvertQualityToString(Domain.Quality quality) - { - // Simplified quality mapping for the new API - var qualityValue = (int)quality; - - if (qualityValue >= 192) // Good family - { - return "Good"; - } - - if (qualityValue >= 64) // Uncertain family - { - return "Uncertain"; - } - - return "Bad"; // Bad family - } - - /// - /// Parses a string value to an appropriate .NET type. - /// - private static object ParseValue(string value) - { - if (string.IsNullOrEmpty(value)) - { - return string.Empty; - } - - // Try to parse as boolean - if (bool.TryParse(value, out bool boolResult)) - { - return boolResult; - } - - // Try to parse as integer - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intResult)) - { - return intResult; - } - - // Try to parse as long - if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long longResult)) - { - return longResult; - } - - // Try to parse as double - if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, - out double doubleResult)) - { - return doubleResult; - } - - // Try to parse as DateTime - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, - out DateTime dateResult)) - { - return dateResult; - } - - // Return as string - return value; - } - - /// - /// Compares two values for equality. - /// - private static bool AreValuesEqual(object value1, object value2) - { - if (value1 == null && value2 == null) - { - return true; - } - - if (value1 == null || value2 == null) - { - return false; - } - - // Convert both to strings for comparison - var str1 = ConvertValueToString(value1); - var str2 = ConvertValueToString(value2); - - return string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase); - } - - #endregion - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs deleted file mode 100644 index 6e1179a..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Connection.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Connection management for MxAccessClient. - /// - public sealed partial class MxAccessClient - { - /// - /// Asynchronously connects to the MxAccess server. - /// - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous connect operation. - /// Thrown if the client has been disposed. - /// Thrown if registration with MxAccess fails. - /// Thrown if any other error occurs during connection. - public async Task ConnectAsync(CancellationToken ct = default) - { - // COM operations must run on STA thread, so we use Task.Run here - await Task.Run(ConnectInternal, ct); - - // Recreate stored subscriptions after successful connection - await RecreateStoredSubscriptionsAsync(); - } - - /// - /// Asynchronously disconnects from the MxAccess server and cleans up resources. - /// - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous disconnect operation. - public async Task DisconnectAsync(CancellationToken ct = default) - { - // COM operations must run on STA thread, so we use Task.Run here - await Task.Run(() => DisconnectInternal(), ct); - } - - /// - /// Internal synchronous connection logic. - /// - private void ConnectInternal() - { - lock (_lock) - { - ValidateNotDisposed(); - - if (IsConnected) - { - return; - } - - try - { - Logger.Information("Attempting to connect to MxAccess"); - SetConnectionState(ConnectionState.Connecting); - - InitializeMxAccessConnection(); - RegisterWithMxAccess(); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to connect to MxAccess"); - Cleanup(); - SetConnectionState(ConnectionState.Disconnected, ex.Message); - throw; - } - } - } - - /// - /// Validates that the client has not been disposed. - /// - private void ValidateNotDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(MxAccessClient)); - } - } - - /// - /// Initializes the MxAccess COM connection and event handlers. - /// - private void InitializeMxAccessConnection() - { - // Create the COM object - _lmxProxy = new LMXProxyServer(); - - // Wire up event handlers - _lmxProxy.OnDataChange += OnDataChange; - _lmxProxy.OnWriteComplete += OnWriteComplete; - _lmxProxy.OperationComplete += OnOperationComplete; - } - - /// - /// Registers with the MxAccess server. - /// - private void RegisterWithMxAccess() - { - // Register with the server - if (_lmxProxy == null) - { - throw new InvalidOperationException("MxAccess proxy is not initialized"); - } - - _connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host"); - - if (_connectionHandle > 0) - { - SetConnectionState(ConnectionState.Connected); - Logger.Information("Successfully connected to MxAccess with handle {Handle}", _connectionHandle); - } - else - { - throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned"); - } - } - - /// - /// Internal synchronous disconnection logic. - /// - private void DisconnectInternal() - { - lock (_lock) - { - if (!IsConnected || _lmxProxy == null) - { - return; - } - - try - { - Logger.Information("Disconnecting from MxAccess"); - SetConnectionState(ConnectionState.Disconnecting); - - RemoveAllSubscriptions(); - UnregisterFromMxAccess(); - - Cleanup(); - SetConnectionState(ConnectionState.Disconnected); - Logger.Information("Successfully disconnected from MxAccess"); - } - catch (Exception ex) - { - Logger.Error(ex, "Error during disconnect"); - Cleanup(); - SetConnectionState(ConnectionState.Disconnected, ex.Message); - } - } - } - - /// - /// Removes all active subscriptions. - /// - private void RemoveAllSubscriptions() - { - var subscriptionsToRemove = _subscriptions.Values.ToList(); - var failedRemovals = new List(); - - foreach (SubscriptionInfo? sub in subscriptionsToRemove) - { - if (!TryRemoveSubscription(sub)) - { - failedRemovals.Add(sub.Address); - } - } - - if (failedRemovals.Any()) - { - Logger.Warning("Failed to cleanly remove {Count} subscriptions: {Addresses}", - failedRemovals.Count, string.Join(", ", failedRemovals)); - } - - _subscriptions.Clear(); - _subscriptionsByHandle.Clear(); - // Note: We intentionally keep _storedSubscriptions to recreate them on reconnect - } - - /// - /// Attempts to remove a single subscription. - /// - private bool TryRemoveSubscription(SubscriptionInfo subscription) - { - try - { - if (_lmxProxy == null) - { - return false; - } - - _lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle); - _lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle); - return true; - } - catch (Exception ex) - { - Logger.Warning(ex, "Error removing subscription for {Address}", subscription.Address); - return false; - } - } - - /// - /// Unregisters from the MxAccess server. - /// - private void UnregisterFromMxAccess() - { - if (_connectionHandle > 0 && _lmxProxy != null) - { - _lmxProxy.Unregister(_connectionHandle); - _connectionHandle = 0; - } - } - - /// - /// Cleans up resources and releases the COM object. - /// Removes event handlers and releases the proxy COM object if present. - /// - private void Cleanup() - { - try - { - if (_lmxProxy != null) - { - // Remove event handlers - _lmxProxy.OnDataChange -= OnDataChange; - _lmxProxy.OnWriteComplete -= OnWriteComplete; - _lmxProxy.OperationComplete -= OnOperationComplete; - - // Release COM object - int refCount = Marshal.ReleaseComObject(_lmxProxy); - if (refCount > 0) - { - Logger.Warning("COM object reference count after release: {RefCount}", refCount); - // Force final release - while (refCount > 0) - { - refCount = Marshal.ReleaseComObject(_lmxProxy); - } - } - - _lmxProxy = null; - } - - _connectionHandle = 0; - } - catch (Exception ex) - { - Logger.Warning(ex, "Error during cleanup"); - } - } - - /// - /// Recreates all stored subscriptions after reconnection. - /// - private async Task RecreateStoredSubscriptionsAsync() - { - List subscriptionsToRecreate; - - lock (_lock) - { - // Create a copy to avoid holding the lock during async operations - subscriptionsToRecreate = new List(_storedSubscriptions); - } - - if (subscriptionsToRecreate.Count == 0) - { - Logger.Debug("No stored subscriptions to recreate"); - return; - } - - Logger.Information("Recreating {Count} stored subscription groups after reconnection", - subscriptionsToRecreate.Count); - - foreach (StoredSubscription? storedSub in subscriptionsToRecreate) - { - try - { - // Recreate the subscription without storing it again - await SubscribeInternalAsync(storedSub.Addresses, storedSub.Callback, false); - - Logger.Information("Successfully recreated subscription group {GroupId} with {Count} addresses", - storedSub.GroupId, storedSub.Addresses.Count); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to recreate subscription group {GroupId}", storedSub.GroupId); - } - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs deleted file mode 100644 index 8735737..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.EventHandlers.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using ArchestrA.MxAccess; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Event handlers for MxAccessClient to process data changes, write completions, and operation completions. - /// - public sealed partial class MxAccessClient - { - /// - /// Handles data change events from the MxAccess server. - /// - /// Server handle. - /// Item handle. - /// Item value. - /// Item quality code. - /// Item timestamp. - /// Status array. - private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue, - int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - if (!_subscriptionsByHandle.TryGetValue(phItemHandle, out SubscriptionInfo? subscription)) - { - return; - } - - // Convert quality from integer - Quality quality = ConvertQuality(pwItemQuality); - DateTime timestamp = ConvertTimestamp(pftItemTimeStamp); - var vtq = new Vtq(pvItemValue, timestamp, quality); - - // Invoke callback - subscription.Callback?.Invoke(subscription.Address, vtq); - } - catch (Exception ex) - { - Logger.Error(ex, "Error processing data change for handle {Handle}", phItemHandle); - } - } - - /// - /// Handles write completion events from the MxAccess server. - /// - /// Server handle. - /// Item handle. - /// Status array. - private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - WriteOperation? writeOp; - - lock (_lock) - { - if (_pendingWrites.TryGetValue(phItemHandle, out writeOp)) - { - _pendingWrites.Remove(phItemHandle); - } - } - - if (writeOp != null) - { - try - { - if (ItemStatus is { Length: > 0 }) - { - var status = ItemStatus[0]; - if (status.success == 0) - { - string errorMsg = GetWriteErrorMessage(status.detail); - Logger.Warning( - "Write failed for {Address} (handle {Handle}): {Error} (Category={Category}, Detail={Detail})", - writeOp.Address, phItemHandle, errorMsg, status.category, status.detail); - - writeOp.CompletionSource.TrySetException(new InvalidOperationException( - $"Write failed: {errorMsg}")); - } - else - { - Logger.Debug("Write completed successfully for {Address} (handle {Handle})", - writeOp.Address, phItemHandle); - writeOp.CompletionSource.TrySetResult(true); - } - } - else - { - Logger.Debug("Write completed for {Address} (handle {Handle}) with no status", - writeOp.Address, phItemHandle); - writeOp.CompletionSource.TrySetResult(true); - } - } - finally - { - // Clean up the item after write completes - lock (_lock) - { - if (_lmxProxy != null) - { - try - { - _lmxProxy.UnAdvise(_connectionHandle, phItemHandle); - _lmxProxy.RemoveItem(_connectionHandle, phItemHandle); - } - catch (Exception ex) - { - Logger.Debug(ex, "Error cleaning up after write for handle {Handle}", phItemHandle); - } - } - } - } - } - else if (ItemStatus is { Length: > 0 }) - { - var status = ItemStatus[0]; - if (status.success == 0) - { - Logger.Warning("Write failed for unknown handle {Handle}: Category={Category}, Detail={Detail}", - phItemHandle, status.category, status.detail); - } - } - } - catch (Exception ex) - { - Logger.Error(ex, "Error processing write complete for handle {Handle}", phItemHandle); - } - } - - /// - /// Handles operation completion events from the MxAccess server. - /// - /// Server handle. - /// Item handle. - /// Status array. - private void OnOperationComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus) - { - // Log operation completion - Logger.Debug("Operation complete for handle {Handle}", phItemHandle); - } - - /// - /// Converts an integer MxAccess quality code to . - /// - /// The MxAccess quality code. - /// The corresponding value. - private Quality ConvertQuality(int mxQuality) => (Quality)mxQuality; - - /// - /// Converts a timestamp object to in UTC. - /// - /// The timestamp object. - /// The UTC value. - private DateTime ConvertTimestamp(object timestamp) - { - if (timestamp is DateTime dt) - { - return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); - } - - return DateTime.UtcNow; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs deleted file mode 100644 index d501d82..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.NestedTypes.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Private nested types for MxAccessClient to encapsulate subscription and write operation details. - /// - public sealed partial class MxAccessClient - { - /// - /// Holds information about a subscription to a SCADA tag. - /// - private class SubscriptionInfo - { - /// - /// Gets or sets the address of the tag. - /// - public string Address { get; set; } = string.Empty; - - /// - /// Gets or sets the item handle. - /// - public int ItemHandle { get; set; } - - /// - /// Gets or sets the callback for value changes. - /// - public Action? Callback { get; set; } - - /// - /// Gets or sets the subscription identifier. - /// - public string SubscriptionId { get; set; } = string.Empty; - } - - /// - /// Represents a handle for a subscription, allowing asynchronous disposal. - /// - private class SubscriptionHandle : IAsyncDisposable - { - private readonly MxAccessClient _client; - private readonly string _groupId; - private readonly List _subscriptionIds; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The owning . - /// The subscription identifiers. - /// The group identifier for stored subscriptions. - public SubscriptionHandle(MxAccessClient client, List subscriptionIds, string groupId) - { - _client = client; - _subscriptionIds = subscriptionIds; - _groupId = groupId; - } - - /// - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - - var tasks = new List(); - foreach (string? id in _subscriptionIds) - { - tasks.Add(_client.UnsubscribeInternalAsync(id)); - } - - await Task.WhenAll(tasks); - - // Remove the stored subscription group - _client.RemoveStoredSubscription(_groupId); - } - } - - /// - /// Represents a pending write operation. - /// - private class WriteOperation - { - /// - /// Gets or sets the address of the tag. - /// - public string Address { get; set; } = string.Empty; - - /// - /// Gets or sets the item handle. - /// - public int ItemHandle { get; set; } - - /// - /// Gets or sets the completion source for the write operation. - /// - public TaskCompletionSource CompletionSource { get; set; } = null!; - - /// - /// Gets or sets the start time of the write operation. - /// - public DateTime StartTime { get; set; } - } - - /// - /// Stores subscription information for automatic recreation after reconnection. - /// - private class StoredSubscription - { - /// - /// Gets or sets the addresses that were subscribed to. - /// - public List Addresses { get; set; } = new(); - - /// - /// Gets or sets the callback for value changes. - /// - public Action Callback { get; set; } = null!; - - /// - /// Gets or sets the unique identifier for this stored subscription group. - /// - public string GroupId { get; set; } = string.Empty; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs deleted file mode 100644 index 11f836d..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.ReadWrite.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Polly; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Services; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Read and write operations for MxAccessClient. - /// - public sealed partial class MxAccessClient - { - /// - public async Task ReadAsync(string address, CancellationToken ct = default) - { - // Apply retry policy for read operations - IAsyncPolicy policy = RetryPolicies.CreateReadPolicy(); - return await policy.ExecuteWithRetryAsync(async () => - { - ValidateConnection(); - return await ReadSingleValueAsync(address, ct); - }, $"Read-{address}"); - } - - /// - public async Task> ReadBatchAsync(IEnumerable addresses, - CancellationToken ct = default) - { - var addressList = addresses.ToList(); - var results = new Dictionary(addressList.Count); - - // Create tasks for parallel reading - IEnumerable tasks = - addressList.Select(address => ReadAddressWithSemaphoreAsync(address, results, ct)); - - await Task.WhenAll(tasks); - return results; - } - - /// - public async Task WriteAsync(string address, object value, CancellationToken ct = default) - { - // Apply retry policy for write operations - IAsyncPolicy policy = RetryPolicies.CreateWritePolicy(); - await policy.ExecuteWithRetryAsync(async () => { await WriteInternalAsync(address, value, ct); }, - $"Write-{address}"); - } - - /// - public async Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) - { - // Create tasks for parallel writing - IEnumerable tasks = values.Select(kvp => WriteAddressWithSemaphoreAsync(kvp.Key, kvp.Value, ct)); - - await Task.WhenAll(tasks); - } - - /// - public async Task WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagAddress, - object flagValue, - string responseAddress, - object responseValue, - CancellationToken ct = default) - { - // Write the batch values - await WriteBatchAsync(values, ct); - - // Write the flag - await WriteAsync(flagAddress, flagValue, ct); - - // Wait for the response - return await WaitForResponseAsync(responseAddress, responseValue, ct); - } - - #region Private Helper Methods - - /// - /// Validates that the client is connected. - /// - private void ValidateConnection() - { - if (!IsConnected) - { - throw new InvalidOperationException("Not connected to MxAccess"); - } - } - - /// - /// Reads a single value from the specified address. - /// - private async Task ReadSingleValueAsync(string address, CancellationToken ct) - { - // MxAccess doesn't support direct read - we need to subscribe, get the value, then unsubscribe - var tcs = new TaskCompletionSource(); - IAsyncDisposable? subscription = null; - - try - { - subscription = await SubscribeAsync(new[] { address }, (addr, vtq) => { tcs.TrySetResult(vtq); }, ct); - - return await WaitForReadResultAsync(tcs, ct); - } - finally - { - if (subscription != null) - { - await subscription.DisposeAsync(); - } - } - } - - /// - /// Waits for a read result with timeout. - /// - private async Task WaitForReadResultAsync(TaskCompletionSource tcs, CancellationToken ct) - { - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_configuration.ReadTimeoutSeconds))) - { - using (ct.Register(() => cts.Cancel())) - { - cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Read timeout"))); - return await tcs.Task; - } - } - } - - /// - /// Reads an address with semaphore protection for batch operations. - /// - private async Task ReadAddressWithSemaphoreAsync(string address, Dictionary results, - CancellationToken ct) - { - await _readSemaphore.WaitAsync(ct); - try - { - Vtq vtq = await ReadAsync(address, ct); - lock (results) - { - results[address] = vtq; - } - } - catch (Exception ex) - { - Logger.Warning(ex, "Failed to read {Address}", address); - lock (results) - { - results[address] = Vtq.Bad(); - } - } - finally - { - _readSemaphore.Release(); - } - } - - /// - /// Internal write implementation. - /// - private async Task WriteInternalAsync(string address, object value, CancellationToken ct) - { - var tcs = new TaskCompletionSource(); - int itemHandle = await SetupWriteOperationAsync(address, value, tcs, ct); - - try - { - await WaitForWriteCompletionAsync(tcs, itemHandle, address, ct); - } - catch - { - await CleanupWriteOperationAsync(itemHandle); - throw; - } - } - - /// - /// Sets up a write operation and returns the item handle. - /// - private async Task SetupWriteOperationAsync(string address, object value, TaskCompletionSource tcs, - CancellationToken ct) - { - return await Task.Run(() => - { - lock (_lock) - { - ValidateConnectionLocked(); - return InitiateWriteOperation(address, value, tcs); - } - }, ct); - } - - /// - /// Validates connection while holding the lock. - /// - private void ValidateConnectionLocked() - { - if (!IsConnected || _lmxProxy == null) - { - throw new InvalidOperationException("Not connected to MxAccess"); - } - } - - /// - /// Initiates a write operation and returns the item handle. - /// - private int InitiateWriteOperation(string address, object value, TaskCompletionSource tcs) - { - int itemHandle = 0; - try - { - if (_lmxProxy == null) - { - throw new InvalidOperationException("MxAccess proxy is not initialized"); - } - - // Add the item if not already added - itemHandle = _lmxProxy.AddItem(_connectionHandle, address); - - // Advise the item to enable writing - _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle); - - // Track the pending write operation - TrackPendingWrite(address, itemHandle, tcs); - - // Write the value - _lmxProxy.Write(_connectionHandle, itemHandle, value, -1); // -1 for no security - - return itemHandle; - } - catch (Exception ex) - { - CleanupFailedWrite(itemHandle); - Logger.Error(ex, "Failed to write value to {Address}", address); - throw; - } - } - - /// - /// Tracks a pending write operation. - /// - private void TrackPendingWrite(string address, int itemHandle, TaskCompletionSource tcs) - { - var writeOp = new WriteOperation - { - Address = address, - ItemHandle = itemHandle, - CompletionSource = tcs, - StartTime = DateTime.UtcNow - }; - _pendingWrites[itemHandle] = writeOp; - } - - /// - /// Cleans up a failed write operation. - /// - private void CleanupFailedWrite(int itemHandle) - { - if (itemHandle > 0 && _lmxProxy != null) - { - try - { - _lmxProxy.UnAdvise(_connectionHandle, itemHandle); - _lmxProxy.RemoveItem(_connectionHandle, itemHandle); - _pendingWrites.Remove(itemHandle); - } - catch - { - } - } - } - - /// - /// Waits for write completion with timeout. - /// - private async Task WaitForWriteCompletionAsync(TaskCompletionSource tcs, int itemHandle, string address, - CancellationToken ct) - { - using (ct.Register(() => tcs.TrySetCanceled())) - { - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(_configuration.WriteTimeoutSeconds), ct); - Task? completedTask = await Task.WhenAny(tcs.Task, timeoutTask); - - if (completedTask == timeoutTask) - { - await HandleWriteTimeoutAsync(itemHandle, address); - } - - await tcs.Task; // This will throw if the write failed - } - } - - /// - /// Handles write timeout by cleaning up resources. - /// - private async Task HandleWriteTimeoutAsync(int itemHandle, string address) - { - await CleanupWriteOperationAsync(itemHandle); - throw new TimeoutException($"Write operation to {address} timed out"); - } - - /// - /// Cleans up a write operation. - /// - private async Task CleanupWriteOperationAsync(int itemHandle) - { - await Task.Run(() => - { - lock (_lock) - { - if (_pendingWrites.ContainsKey(itemHandle)) - { - _pendingWrites.Remove(itemHandle); - if (_lmxProxy != null) - { - try - { - _lmxProxy.UnAdvise(_connectionHandle, itemHandle); - _lmxProxy.RemoveItem(_connectionHandle, itemHandle); - } - catch - { - } - } - } - } - }); - } - - /// - /// Writes an address with semaphore protection for batch operations. - /// - private async Task WriteAddressWithSemaphoreAsync(string address, object value, CancellationToken ct) - { - await _writeSemaphore.WaitAsync(ct); - try - { - await WriteAsync(address, value, ct); - } - finally - { - _writeSemaphore.Release(); - } - } - - /// - /// Waits for a specific response value. - /// - private async Task WaitForResponseAsync(string responseAddress, object responseValue, - CancellationToken ct) - { - var tcs = new TaskCompletionSource(); - IAsyncDisposable? subscription = null; - - try - { - subscription = await SubscribeAsync(new[] { responseAddress }, (addr, vtq) => - { - if (Equals(vtq.Value, responseValue)) - { - tcs.TrySetResult(true); - } - }, ct); - - // Wait for the response value - using (ct.Register(() => tcs.TrySetResult(false))) - { - return await tcs.Task; - } - } - finally - { - if (subscription != null) - { - await subscription.DisposeAsync(); - } - } - } - - /// - /// Gets a human-readable error message for a write error code. - /// - /// The error code. - /// The error message. - private static string GetWriteErrorMessage(int errorCode) - { - return errorCode switch - { - 1008 => "User lacks proper security for write operation", - 1012 => "Secured write required", - 1013 => "Verified write required", - _ => $"Unknown error code: {errorCode}" - }; - } - - #endregion - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs deleted file mode 100644 index 88c4c12..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.Subscription.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Subscription management for MxAccessClient to handle SCADA tag updates. - /// - public sealed partial class MxAccessClient - { - /// - /// Subscribes to a set of addresses and registers a callback for value changes. - /// - /// The collection of addresses to subscribe to. - /// - /// The callback to invoke when a value changes. - /// The callback receives the address and the new value. - /// - /// An optional to cancel the operation. - /// - /// A that completes with a handle to the subscription. - /// Disposing the handle will unsubscribe from all addresses. - /// - /// Thrown if not connected to MxAccess. - /// Thrown if subscription fails for any address. - public Task SubscribeAsync(IEnumerable addresses, Action callback, - CancellationToken ct = default) => SubscribeInternalAsync(addresses, callback, true, ct); - - /// - /// Internal subscription method that allows control over whether to store the subscription for recreation. - /// - private Task SubscribeInternalAsync(IEnumerable addresses, - Action callback, bool storeForRecreation, CancellationToken ct = default) - { - return Task.Run(() => - { - lock (_lock) - { - if (!IsConnected || _lmxProxy == null) - { - throw new InvalidOperationException("Not connected to MxAccess"); - } - - var subscriptionIds = new List(); - - try - { - var addressList = addresses.ToList(); - - foreach (string? address in addressList) - { - // Add the item - var itemHandle = _lmxProxy.AddItem(_connectionHandle, address); - - // Create subscription info - string subscriptionId = Guid.NewGuid().ToString(); - var subscription = new SubscriptionInfo - { - Address = address, - ItemHandle = itemHandle, - Callback = callback, - SubscriptionId = subscriptionId - }; - - // Store subscription - _subscriptions[subscriptionId] = subscription; - _subscriptionsByHandle[itemHandle] = subscription; - subscriptionIds.Add(subscriptionId); - - // Advise the item - _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle); - - Logger.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle); - } - - // Store subscription group for automatic recreation after reconnect - string groupId = Guid.NewGuid().ToString(); - - if (storeForRecreation) - { - _storedSubscriptions.Add(new StoredSubscription - { - Addresses = addressList, - Callback = callback, - GroupId = groupId - }); - - Logger.Debug( - "Stored subscription group {GroupId} with {Count} addresses for automatic recreation", - groupId, addressList.Count); - } - - return new SubscriptionHandle(this, subscriptionIds, groupId); - } - catch (Exception ex) - { - // Clean up any subscriptions that were created - foreach (string? id in subscriptionIds) - { - UnsubscribeInternalAsync(id).Wait(); - } - - Logger.Error(ex, "Failed to subscribe to addresses"); - throw; - } - } - }, ct); - } - - /// - /// Unsubscribes from a subscription by its ID. - /// - /// The subscription identifier. - /// - /// A representing the asynchronous operation. - /// - private Task UnsubscribeInternalAsync(string subscriptionId) - { - return Task.Run(() => - { - lock (_lock) - { - if (!_subscriptions.TryGetValue(subscriptionId, out SubscriptionInfo? subscription)) - { - return; - } - - try - { - if (_lmxProxy != null && _connectionHandle > 0) - { - _lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle); - _lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle); - } - - _subscriptions.Remove(subscriptionId); - _subscriptionsByHandle.Remove(subscription.ItemHandle); - - Logger.Debug("Unsubscribed from {Address}", subscription.Address); - } - catch (Exception ex) - { - Logger.Warning(ex, "Error unsubscribing from {Address}", subscription.Address); - } - } - }); - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs deleted file mode 100644 index 1db4225..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Implementation/MxAccessClient.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Implementation -{ - /// - /// Implementation of using ArchestrA MxAccess. - /// Provides connection management, read/write operations, and subscription support for SCADA tags. - /// - public sealed partial class MxAccessClient : IScadaClient - { - private const int DefaultMaxConcurrency = 10; - private static readonly ILogger Logger = Log.ForContext(); - private readonly ConnectionConfiguration _configuration; - - private readonly object _lock = new(); - private readonly Dictionary _pendingWrites = new(); - - // Concurrency control for batch operations - private readonly SemaphoreSlim _readSemaphore; - - // Store subscription details for automatic recreation after reconnect - private readonly List _storedSubscriptions = new(); - private readonly Dictionary _subscriptions = new(); - private readonly Dictionary _subscriptionsByHandle = new(); - private readonly SemaphoreSlim _writeSemaphore; - private int _connectionHandle; - private ConnectionState _connectionState = ConnectionState.Disconnected; - private bool _disposed; - private LMXProxyServer? _lmxProxy; - - /// - /// Initializes a new instance of the class. - /// - /// The connection configuration settings. - public MxAccessClient(ConnectionConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - - // Initialize semaphores with configurable concurrency limits - int maxConcurrency = _configuration.MaxConcurrentOperations ?? DefaultMaxConcurrency; - _readSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - _writeSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - } - - /// - public bool IsConnected - { - get - { - lock (_lock) - { - return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0; - } - } - } - - /// - public ConnectionState ConnectionState - { - get - { - lock (_lock) - { - return _connectionState; - } - } - } - - /// - /// Occurs when the connection state changes. - /// - public event EventHandler? ConnectionStateChanged; - - /// - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - await DisconnectAsync(); - _disposed = true; - - // Dispose semaphores - _readSemaphore?.Dispose(); - _writeSemaphore?.Dispose(); - } - - /// - public void Dispose() => DisposeAsync().GetAwaiter().GetResult(); - - /// - /// Sets the connection state and raises the event. - /// - /// The new connection state. - /// Optional message describing the state change. - private void SetConnectionState(ConnectionState newState, string? message = null) - { - ConnectionState previousState = _connectionState; - if (previousState == newState) - { - return; - } - - _connectionState = newState; - Logger.Information("Connection state changed from {Previous} to {Current}", previousState, newState); - - ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previousState, newState, message)); - } - - /// - /// Removes a stored subscription group by its ID. - /// - /// The group identifier to remove. - private void RemoveStoredSubscription(string groupId) - { - lock (_lock) - { - _storedSubscriptions.RemoveAll(s => s.GroupId == groupId); - Logger.Debug("Removed stored subscription group {GroupId}", groupId); - } - } -#pragma warning disable CS0169 // Field is never used - reserved for future functionality - private string? _currentNodeName; - private string? _currentGalaxyName; -#pragma warning restore CS0169 - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs deleted file mode 100644 index 403abee..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs +++ /dev/null @@ -1,592 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Grpc.Services; -using ZB.MOM.WW.LmxProxy.Host.Implementation; -using ZB.MOM.WW.LmxProxy.Host.Security; -using ZB.MOM.WW.LmxProxy.Host.Services; -using ZB.MOM.WW.LmxProxy.Host.Grpc; -using ConnectionState = ZB.MOM.WW.LmxProxy.Host.Domain.ConnectionState; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - /// - /// Windows service that hosts the gRPC server and MxAccess client. - /// Manages lifecycle of gRPC server, SCADA client, subscription manager, and API key service. - /// - public class LmxProxyService - { - private static readonly ILogger Logger = Log.ForContext(); - private readonly LmxProxyConfiguration _configuration; - private readonly SemaphoreSlim _reconnectSemaphore = new(1, 1); - private readonly Func _scadaClientFactory; - private readonly CancellationTokenSource _shutdownCts = new(); - private ApiKeyService? _apiKeyService; - private Task? _connectionMonitorTask; - private DetailedHealthCheckService? _detailedHealthCheckService; - - private Server? _grpcServer; - private HealthCheckService? _healthCheckService; - private PerformanceMetrics? _performanceMetrics; - private IScadaClient? _scadaClient; - private SessionManager? _sessionManager; - private StatusReportService? _statusReportService; - private StatusWebServer? _statusWebServer; - private SubscriptionManager? _subscriptionManager; - - /// - /// Initializes a new instance of the class. - /// - /// Configuration settings for the service. - /// Thrown if configuration is null. - public LmxProxyService(LmxProxyConfiguration configuration, - Func? scadaClientFactory = null) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _scadaClientFactory = scadaClientFactory ?? (config => new MxAccessClient(config.Connection)); - } - - /// - /// Starts the LmxProxy service, initializing all required components and starting the gRPC server. - /// - /// true if the service started successfully; otherwise, false. - public bool Start() - { - try - { - Logger.Information("Starting LmxProxy service on port {Port}", _configuration.GrpcPort); - - // Validate configuration before proceeding - if (!ValidateConfiguration()) - { - Logger.Error("Configuration validation failed"); - return false; - } - - // Check and ensure TLS certificates are valid - if (_configuration.Tls.Enabled) - { - Logger.Information("Checking TLS certificate configuration"); - var tlsManager = new TlsCertificateManager(_configuration.Tls); - if (!tlsManager.EnsureCertificatesValid()) - { - Logger.Error("Failed to ensure valid TLS certificates"); - throw new InvalidOperationException("TLS certificate validation or generation failed"); - } - - Logger.Information("TLS certificates validated successfully"); - } - - // Create performance metrics service - _performanceMetrics = new PerformanceMetrics(); - Logger.Information("Performance metrics service initialized"); - - // Create API key service - string apiKeyConfigPath = Path.GetFullPath(_configuration.ApiKeyConfigFile); - _apiKeyService = new ApiKeyService(apiKeyConfigPath); - Logger.Information("API key service initialized with config file: {ConfigFile}", apiKeyConfigPath); - - // Create SCADA client via factory - _scadaClient = _scadaClientFactory(_configuration) ?? - throw new InvalidOperationException("SCADA client factory returned null."); - - // Subscribe to connection state changes - _scadaClient.ConnectionStateChanged += OnConnectionStateChanged; - - // Automatically connect to MxAccess on startup - try - { - Logger.Information("Connecting to MxAccess..."); - Task connectTask = _scadaClient.ConnectAsync(); - if (!connectTask.Wait(TimeSpan.FromSeconds(_configuration.Connection.ConnectionTimeoutSeconds))) - { - throw new TimeoutException( - $"Timeout connecting to MxAccess after {_configuration.Connection.ConnectionTimeoutSeconds} seconds"); - } - - Logger.Information("Successfully connected to MxAccess"); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to connect to MxAccess on startup"); - throw; - } - - // Start connection monitoring if auto-reconnect is enabled - if (_configuration.Connection.AutoReconnect) - { - _connectionMonitorTask = Task.Run(() => MonitorConnectionAsync(_shutdownCts.Token)); - Logger.Information("Connection monitoring started with {Interval} second interval", - _configuration.Connection.MonitorIntervalSeconds); - } - - // Create subscription manager with configuration - _subscriptionManager = new SubscriptionManager(_scadaClient, _configuration.Subscription); - - // Create session manager for tracking client sessions - _sessionManager = new SessionManager(); - Logger.Information("Session manager initialized"); - - // Create health check services - _healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics); - _detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient); - Logger.Information("Health check services initialized"); - - // Create status report service and web server - _statusReportService = new StatusReportService( - _scadaClient, - _subscriptionManager, - _performanceMetrics, - _healthCheckService, - _detailedHealthCheckService); - - _statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService); - Logger.Information("Status web server initialized"); - - // Create gRPC service with session manager and performance metrics - var scadaService = new ScadaGrpcService(_scadaClient, _subscriptionManager, _sessionManager, _performanceMetrics); - - // Create API key interceptor - var apiKeyInterceptor = new ApiKeyInterceptor(_apiKeyService); - - // Configure server credentials based on TLS configuration - ServerCredentials serverCredentials; - if (_configuration.Tls.Enabled) - { - serverCredentials = CreateTlsCredentials(_configuration.Tls); - Logger.Information("TLS enabled for gRPC server"); - } - else - { - serverCredentials = ServerCredentials.Insecure; - Logger.Warning("gRPC server running without TLS encryption - not recommended for production"); - } - - // Configure and start gRPC server with interceptor - _grpcServer = new Server - { - Services = { ScadaService.BindService(scadaService).Intercept(apiKeyInterceptor) }, - Ports = { new ServerPort("0.0.0.0", _configuration.GrpcPort, serverCredentials) } - }; - - _grpcServer.Start(); - - string securityMode = _configuration.Tls.Enabled ? "TLS/SSL" : "INSECURE"; - Logger.Information("LmxProxy service started successfully on port {Port} ({SecurityMode})", - _configuration.GrpcPort, securityMode); - Logger.Information("gRPC server listening on 0.0.0.0:{Port}", _configuration.GrpcPort); - - // Start status web server - if (_statusWebServer != null && !_statusWebServer.Start()) - { - Logger.Warning("Failed to start status web server, continuing without it"); - } - - return true; - } - catch (Exception ex) - { - Logger.Fatal(ex, "Failed to start LmxProxy service"); - return false; - } - } - - /// - /// Stops the LmxProxy service, shutting down the gRPC server and disposing all resources. - /// - /// true if the service stopped successfully; otherwise, false. - public bool Stop() - { - try - { - Logger.Information("Stopping LmxProxy service"); - - _shutdownCts.Cancel(); - - // Stop connection monitoring - if (_connectionMonitorTask != null) - { - try - { - _connectionMonitorTask.Wait(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - Logger.Warning(ex, "Error stopping connection monitor"); - } - } - - // Shutdown gRPC server - if (_grpcServer != null) - { - Logger.Information("Shutting down gRPC server"); - Task? shutdownTask = _grpcServer.ShutdownAsync(); - - // Wait up to 10 seconds for graceful shutdown - if (!shutdownTask.Wait(TimeSpan.FromSeconds(10))) - { - Logger.Warning("gRPC server shutdown timeout, forcing kill"); - _grpcServer.KillAsync().Wait(TimeSpan.FromSeconds(5)); - } - - _grpcServer = null; - } - - // Stop status web server - if (_statusWebServer != null) - { - Logger.Information("Stopping status web server"); - try - { - _statusWebServer.Stop(); - _statusWebServer.Dispose(); - _statusWebServer = null; - } - catch (Exception ex) - { - Logger.Warning(ex, "Error stopping status web server"); - } - } - - // Dispose status report service - if (_statusReportService != null) - { - Logger.Information("Disposing status report service"); - _statusReportService = null; - } - - // Dispose health check services - if (_detailedHealthCheckService != null) - { - Logger.Information("Disposing detailed health check service"); - _detailedHealthCheckService = null; - } - - if (_healthCheckService != null) - { - Logger.Information("Disposing health check service"); - _healthCheckService = null; - } - - // Dispose subscription manager - if (_subscriptionManager != null) - { - Logger.Information("Disposing subscription manager"); - _subscriptionManager.Dispose(); - _subscriptionManager = null; - } - - // Dispose session manager - if (_sessionManager != null) - { - Logger.Information("Disposing session manager"); - _sessionManager.Dispose(); - _sessionManager = null; - } - - // Dispose API key service - if (_apiKeyService != null) - { - Logger.Information("Disposing API key service"); - _apiKeyService.Dispose(); - _apiKeyService = null; - } - - // Dispose performance metrics - if (_performanceMetrics != null) - { - Logger.Information("Disposing performance metrics service"); - _performanceMetrics.Dispose(); - _performanceMetrics = null; - } - - // Disconnect and dispose SCADA client - if (_scadaClient != null) - { - Logger.Information("Disconnecting SCADA client"); - - // Unsubscribe from events - _scadaClient.ConnectionStateChanged -= OnConnectionStateChanged; - - try - { - Task disconnectTask = _scadaClient.DisconnectAsync(); - if (!disconnectTask.Wait(TimeSpan.FromSeconds(10))) - { - Logger.Warning("SCADA client disconnect timeout"); - } - } - catch (Exception ex) - { - Logger.Warning(ex, "Error disconnecting SCADA client"); - } - - try - { - Task? disposeTask = _scadaClient.DisposeAsync().AsTask(); - if (!disposeTask.Wait(TimeSpan.FromSeconds(5))) - { - Logger.Warning("SCADA client dispose timeout"); - } - } - catch (Exception ex) - { - Logger.Warning(ex, "Error disposing SCADA client"); - } - - _scadaClient = null; - } - - Logger.Information("LmxProxy service stopped successfully"); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Error stopping LmxProxy service"); - return false; - } - } - - /// - /// Pauses the LmxProxy service. No operation is performed except logging. - /// - public void Pause() => Logger.Information("LmxProxy service paused"); - - /// - /// Continues the LmxProxy service after a pause. No operation is performed except logging. - /// - public void Continue() => Logger.Information("LmxProxy service continued"); - - /// - /// Requests shutdown of the LmxProxy service and stops all components. - /// - public void Shutdown() - { - Logger.Information("LmxProxy service shutdown requested"); - Stop(); - } - - /// - /// Handles connection state changes from the SCADA client. - /// - private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e) - { - Logger.Information("MxAccess connection state changed from {Previous} to {Current}", - e.PreviousState, e.CurrentState); - - if (e.CurrentState == ConnectionState.Disconnected && - e.PreviousState == ConnectionState.Connected) - { - Logger.Warning("MxAccess connection lost. Automatic reconnection will be attempted."); - } - } - - /// - /// Monitors the connection and attempts to reconnect when disconnected. - /// - private async Task MonitorConnectionAsync(CancellationToken cancellationToken) - { - Logger.Information("Starting connection monitor"); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromSeconds(_configuration.Connection.MonitorIntervalSeconds), - cancellationToken); - - if (_scadaClient != null && !_scadaClient.IsConnected && !cancellationToken.IsCancellationRequested) - { - await _reconnectSemaphore.WaitAsync(cancellationToken); - try - { - if (_scadaClient != null && !_scadaClient.IsConnected) - { - Logger.Information("Attempting to reconnect to MxAccess..."); - - try - { - await _scadaClient.ConnectAsync(cancellationToken); - Logger.Information("Successfully reconnected to MxAccess"); - } - catch (Exception ex) - { - Logger.Warning(ex, - "Failed to reconnect to MxAccess. Will retry in {Interval} seconds.", - _configuration.Connection.MonitorIntervalSeconds); - } - } - } - finally - { - _reconnectSemaphore.Release(); - } - } - } - catch (OperationCanceledException) - { - // Expected when shutting down - break; - } - catch (Exception ex) - { - Logger.Error(ex, "Error in connection monitor"); - } - } - - Logger.Information("Connection monitor stopped"); - } - - /// - /// Creates TLS server credentials from configuration - /// - private static ServerCredentials CreateTlsCredentials(TlsConfiguration tlsConfig) - { - try - { - // Read certificate and key files - string serverCert = File.ReadAllText(tlsConfig.ServerCertificatePath); - string serverKey = File.ReadAllText(tlsConfig.ServerKeyPath); - - var keyCertPairs = new List - { - new(serverCert, serverKey) - }; - - // Configure client certificate requirements - if (tlsConfig.RequireClientCertificate && !string.IsNullOrWhiteSpace(tlsConfig.ClientCaCertificatePath)) - { - string clientCaCert = File.ReadAllText(tlsConfig.ClientCaCertificatePath); - return new SslServerCredentials( - keyCertPairs, - clientCaCert, - tlsConfig.CheckCertificateRevocation - ? SslClientCertificateRequestType.RequestAndRequireAndVerify - : SslClientCertificateRequestType.RequestAndRequireButDontVerify); - } - - if (tlsConfig.RequireClientCertificate) - { - // Require client certificate but no CA specified - use system CA - return new SslServerCredentials( - keyCertPairs, - null, - SslClientCertificateRequestType.RequestAndRequireAndVerify); - } - - // No client certificate required - return new SslServerCredentials(keyCertPairs); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to create TLS credentials"); - throw new InvalidOperationException("Failed to configure TLS for gRPC server", ex); - } - } - - /// - /// Validates the service configuration and returns false if any critical issues are found - /// - private bool ValidateConfiguration() - { - try - { - // Validate gRPC port - if (_configuration.GrpcPort <= 0 || _configuration.GrpcPort > 65535) - { - Logger.Error("Invalid gRPC port: {Port}. Port must be between 1 and 65535", - _configuration.GrpcPort); - return false; - } - - // Validate API key configuration file - if (string.IsNullOrWhiteSpace(_configuration.ApiKeyConfigFile)) - { - Logger.Error("API key configuration file path is null or empty"); - return false; - } - - // Check if API key file exists or can be created - string apiKeyPath = Path.GetFullPath(_configuration.ApiKeyConfigFile); - string? apiKeyDirectory = Path.GetDirectoryName(apiKeyPath); - - if (!string.IsNullOrEmpty(apiKeyDirectory) && !Directory.Exists(apiKeyDirectory)) - { - try - { - Directory.CreateDirectory(apiKeyDirectory); - } - catch (Exception ex) - { - Logger.Error(ex, "Cannot create directory for API key file: {Directory}", apiKeyDirectory); - return false; - } - } - - // If API key file exists, validate it can be read - if (File.Exists(apiKeyPath)) - { - try - { - string content = File.ReadAllText(apiKeyPath); - if (!string.IsNullOrWhiteSpace(content)) - { - // Try to parse as JSON to validate format - JsonDocument.Parse(content); - } - } - catch (Exception ex) - { - Logger.Error(ex, "API key configuration file is invalid or unreadable: {FilePath}", apiKeyPath); - return false; - } - } - - // Validate TLS configuration if enabled - if (_configuration.Tls.Enabled) - { - if (!_configuration.Tls.Validate()) - { - Logger.Error("TLS configuration validation failed"); - return false; - } - } - - // Validate web server configuration if enabled - if (_configuration.WebServer.Enabled) - { - if (_configuration.WebServer.Port <= 0 || _configuration.WebServer.Port > 65535) - { - Logger.Error("Invalid web server port: {Port}. Port must be between 1 and 65535", - _configuration.WebServer.Port); - return false; - } - - // Check for port conflicts - if (_configuration.WebServer.Port == _configuration.GrpcPort) - { - Logger.Error("Web server port {WebPort} conflicts with gRPC port {GrpcPort}", - _configuration.WebServer.Port, _configuration.GrpcPort); - return false; - } - } - - Logger.Information("Configuration validation passed"); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Error during configuration validation"); - return false; - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs deleted file mode 100644 index c057a30..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.IO; -using Microsoft.Extensions.Configuration; -using Serilog; -using Topshelf; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - internal class Program - { - private static void Main(string[] args) - { - // Build configuration - IConfigurationRoot? configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", true, true) - .AddEnvironmentVariables() - .Build(); - - // Configure Serilog from appsettings.json - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .CreateLogger(); - - try - { - Log.Information("Starting ZB.MOM.WW.LmxProxy.Host"); - - // Load configuration - var config = new LmxProxyConfiguration(); - configuration.Bind(config); - - // Validate configuration - if (!ConfigurationValidator.ValidateAndLog(config)) - { - Log.Fatal("Configuration validation failed. Please check the configuration and try again."); - Environment.ExitCode = 1; - return; - } - - // Configure and run the Windows service using TopShelf - TopshelfExitCode exitCode = HostFactory.Run(hostConfig => - { - hostConfig.Service(serviceConfig => - { - serviceConfig.ConstructUsing(() => new LmxProxyService(config)); - serviceConfig.WhenStarted(service => service.Start()); - serviceConfig.WhenStopped(service => service.Stop()); - serviceConfig.WhenPaused(service => service.Pause()); - serviceConfig.WhenContinued(service => service.Continue()); - serviceConfig.WhenShutdown(service => service.Shutdown()); - }); - - hostConfig.UseSerilog(Log.Logger); - - hostConfig.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); - hostConfig.SetDisplayName("SCADA Bridge LMX Proxy"); - hostConfig.SetDescription("Provides gRPC access to Archestra MxAccess for SCADA Bridge"); - - hostConfig.StartAutomatically(); - hostConfig.EnableServiceRecovery(recoveryConfig => - { - recoveryConfig.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); - recoveryConfig.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); - recoveryConfig.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); - recoveryConfig.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); - }); - - hostConfig.OnException(ex => { Log.Fatal(ex, "Unhandled exception in service"); }); - }); - - Log.Information("Service exited with code: {ExitCode}", exitCode); - Environment.ExitCode = (int)exitCode; - } - catch (Exception ex) - { - Log.Fatal(ex, "Failed to start service"); - Environment.ExitCode = 1; - } - finally - { - Log.CloseAndFlush(); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs deleted file mode 100644 index 9472a8c..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Represents an API key with associated permissions - /// - public class ApiKey - { - /// - /// The API key value - /// - public string Key { get; set; } = string.Empty; - - /// - /// Description of what this API key is used for - /// - public string Description { get; set; } = string.Empty; - - /// - /// The role assigned to this API key - /// - public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly; - - /// - /// Whether this API key is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// Checks if the API key is valid - /// - public bool IsValid() => Enabled; - } - - /// - /// API key roles - /// - public enum ApiKeyRole - { - /// - /// Can only read data - /// - ReadOnly, - - /// - /// Can read and write data - /// - ReadWrite - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs deleted file mode 100644 index 02284be..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Configuration for API keys loaded from file - /// - public class ApiKeyConfiguration - { - /// - /// List of API keys - /// - public List ApiKeys { get; set; } = new(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs deleted file mode 100644 index 4a1a87d..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// gRPC interceptor for API key authentication. - /// Validates API keys for incoming requests and enforces role-based access control. - /// - public class ApiKeyInterceptor : Interceptor - { - private static readonly ILogger Logger = Log.ForContext(); - - /// - /// List of gRPC method names that require write access. - /// - private static readonly string[] WriteMethodNames = - { - "Write", - "WriteBatch", - "WriteBatchAndWait" - }; - - private readonly ApiKeyService _apiKeyService; - - /// - /// Initializes a new instance of the class. - /// - /// The API key service used for validation. - /// Thrown if is null. - public ApiKeyInterceptor(ApiKeyService apiKeyService) - { - _apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService)); - } - - /// - /// Handles unary gRPC calls, validating API key and enforcing permissions. - /// - /// The request type. - /// The response type. - /// The request message. - /// The server call context. - /// The continuation delegate. - /// The response message. - /// Thrown if authentication or authorization fails. - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - string apiKey = GetApiKeyFromContext(context); - string methodName = GetMethodName(context.Method); - - if (string.IsNullOrEmpty(apiKey)) - { - Logger.Warning("Missing API key for method {Method} from {Peer}", - context.Method, context.Peer); - throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required")); - } - - ApiKey? key = _apiKeyService.ValidateApiKey(apiKey); - if (key == null) - { - Logger.Warning("Invalid API key for method {Method} from {Peer}", - context.Method, context.Peer); - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key")); - } - - // Check if method requires write access - if (IsWriteMethod(methodName) && key.Role != ApiKeyRole.ReadWrite) - { - Logger.Warning("Insufficient permissions for method {Method} with API key {Description}", - context.Method, key.Description); - throw new RpcException(new Status(StatusCode.PermissionDenied, - "API key does not have write permissions")); - } - - // Add API key info to context items for use in service methods - context.UserState["ApiKey"] = key; - - Logger.Debug("Authorized method {Method} for API key {Description}", - context.Method, key.Description); - - return await continuation(request, context); - } - - /// - /// Handles server streaming gRPC calls, validating API key and enforcing permissions. - /// - /// The request type. - /// The response type. - /// The request message. - /// The response stream writer. - /// The server call context. - /// The continuation delegate. - /// A task representing the asynchronous operation. - /// Thrown if authentication fails. - public override async Task ServerStreamingServerHandler( - TRequest request, - IServerStreamWriter responseStream, - ServerCallContext context, - ServerStreamingServerMethod continuation) - { - string apiKey = GetApiKeyFromContext(context); - - if (string.IsNullOrEmpty(apiKey)) - { - Logger.Warning("Missing API key for streaming method {Method} from {Peer}", - context.Method, context.Peer); - throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required")); - } - - ApiKey? key = _apiKeyService.ValidateApiKey(apiKey); - if (key == null) - { - Logger.Warning("Invalid API key for streaming method {Method} from {Peer}", - context.Method, context.Peer); - throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key")); - } - - // Add API key info to context items - context.UserState["ApiKey"] = key; - - Logger.Debug("Authorized streaming method {Method} for API key {Description}", - context.Method, key.Description); - - await continuation(request, responseStream, context); - } - - /// - /// Extracts the API key from the gRPC request headers. - /// - /// The server call context. - /// The API key value, or an empty string if not found. - private static string GetApiKeyFromContext(ServerCallContext context) - { - // Check for API key in metadata (headers) - Metadata.Entry? entry = context.RequestHeaders.FirstOrDefault(e => - e.Key.Equals("x-api-key", StringComparison.OrdinalIgnoreCase)); - - return entry?.Value ?? string.Empty; - } - - /// - /// Gets the method name from the full gRPC method string. - /// - /// The full method string (e.g., /package.Service/Method). - /// The method name. - private static string GetMethodName(string method) - { - // Method format is /package.Service/Method - int lastSlash = method.LastIndexOf('/'); - return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method; - } - - /// - /// Determines whether the specified method name requires write access. - /// - /// The method name. - /// true if the method requires write access; otherwise, false. - private static bool IsWriteMethod(string methodName) => - WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs deleted file mode 100644 index bf6f8f5..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Service for managing API keys with file-based storage. - /// Handles validation, role checking, and automatic reload on file changes. - /// - public class ApiKeyService : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - private readonly ConcurrentDictionary _apiKeys; - private readonly string _configFilePath; - private readonly SemaphoreSlim _reloadLock = new(1, 1); - private bool _disposed; - private FileSystemWatcher? _fileWatcher; - private DateTime _lastReloadTime = DateTime.MinValue; - - /// - /// Initializes a new instance of the class. - /// - /// The path to the API key configuration file. - /// Thrown if is null. - public ApiKeyService(string configFilePath) - { - _configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath)); - _apiKeys = new ConcurrentDictionary(); - - InitializeFileWatcher(); - LoadConfiguration(); - } - - /// - /// Disposes the and releases resources. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - _fileWatcher?.Dispose(); - _reloadLock?.Dispose(); - - Logger.Information("API key service disposed"); - } - - /// - /// Validates an API key and returns its details if valid. - /// - /// The API key value to validate. - /// The if valid; otherwise, null. - public ApiKey? ValidateApiKey(string apiKey) - { - if (string.IsNullOrWhiteSpace(apiKey)) - { - return null; - } - - if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid()) - { - Logger.Debug("API key validated successfully for {Description}", key.Description); - return key; - } - - Logger.Warning("Invalid or expired API key attempted"); - return null; - } - - /// - /// Checks if an API key has the specified role. - /// - /// The API key value. - /// The required . - /// true if the API key has the required role; otherwise, false. - public bool HasRole(string apiKey, ApiKeyRole requiredRole) - { - ApiKey? key = ValidateApiKey(apiKey); - if (key == null) - { - return false; - } - - // ReadWrite role has access to everything - if (key.Role == ApiKeyRole.ReadWrite) - { - return true; - } - - // ReadOnly role only has access to ReadOnly operations - return requiredRole == ApiKeyRole.ReadOnly; - } - - /// - /// Initializes the file system watcher for the API key configuration file. - /// - private void InitializeFileWatcher() - { - string? directory = Path.GetDirectoryName(_configFilePath); - string? fileName = Path.GetFileName(_configFilePath); - - if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) - { - Logger.Warning("Invalid config file path, file watching disabled"); - return; - } - - try - { - _fileWatcher = new FileSystemWatcher(directory, fileName) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, - EnableRaisingEvents = true - }; - - _fileWatcher.Changed += OnFileChanged; - _fileWatcher.Created += OnFileChanged; - _fileWatcher.Renamed += OnFileRenamed; - - Logger.Information("File watcher initialized for {FilePath}", _configFilePath); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath); - } - } - - /// - /// Handles file change events for the configuration file. - /// - /// The event sender. - /// The instance containing event data. - private void OnFileChanged(object sender, FileSystemEventArgs e) - { - if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created) - { - Logger.Information("API key configuration file changed, reloading"); - Task.Run(() => ReloadConfigurationAsync()); - } - } - - /// - /// Handles file rename events for the configuration file. - /// - /// The event sender. - /// The instance containing event data. - private void OnFileRenamed(object sender, RenamedEventArgs e) - { - if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase)) - { - Logger.Information("API key configuration file renamed, reloading"); - Task.Run(() => ReloadConfigurationAsync()); - } - } - - /// - /// Asynchronously reloads the API key configuration from file. - /// Debounces rapid file changes to avoid excessive reloads. - /// - private async Task ReloadConfigurationAsync() - { - // Debounce rapid file changes - TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime; - if (timeSinceLastReload < TimeSpan.FromSeconds(1)) - { - await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload); - } - - await _reloadLock.WaitAsync(); - try - { - LoadConfiguration(); - _lastReloadTime = DateTime.UtcNow; - } - finally - { - _reloadLock.Release(); - } - } - - /// - /// Loads the API key configuration from file. - /// If the file does not exist, creates a default configuration. - /// - private void LoadConfiguration() - { - try - { - if (!File.Exists(_configFilePath)) - { - Logger.Warning("API key configuration file not found at {FilePath}, creating default", - _configFilePath); - CreateDefaultConfiguration(); - return; - } - - string json = File.ReadAllText(_configFilePath); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip - }; - options.Converters.Add(new JsonStringEnumConverter()); - ApiKeyConfiguration? config = JsonSerializer.Deserialize(json, options); - - if (config?.ApiKeys == null || !config.ApiKeys.Any()) - { - Logger.Warning("No API keys found in configuration file"); - return; - } - - // Clear existing keys and load new ones - _apiKeys.Clear(); - - foreach (ApiKey? apiKey in config.ApiKeys) - { - if (string.IsNullOrWhiteSpace(apiKey.Key)) - { - Logger.Warning("Skipping API key with empty key value"); - continue; - } - - if (_apiKeys.TryAdd(apiKey.Key, apiKey)) - { - Logger.Information("Loaded API key: {Description} with role {Role}", - apiKey.Description, apiKey.Role); - } - else - { - Logger.Warning("Duplicate API key found: {Description}", apiKey.Description); - } - } - - Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath); - } - } - - /// - /// Creates a default API key configuration file with sample keys. - /// - private void CreateDefaultConfiguration() - { - try - { - var defaultConfig = new ApiKeyConfiguration - { - ApiKeys = new List - { - new() - { - Key = Guid.NewGuid().ToString("N"), - Description = "Default read-only API key", - Role = ApiKeyRole.ReadOnly, - Enabled = true - }, - new() - { - Key = Guid.NewGuid().ToString("N"), - Description = "Default read-write API key", - Role = ApiKeyRole.ReadWrite, - Enabled = true - } - } - }; - - string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions - { - WriteIndented = true - }); - - string? directory = Path.GetDirectoryName(_configFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - File.WriteAllText(_configFilePath, json); - Logger.Information("Created default API key configuration at {FilePath}", _configFilePath); - - // Load the created configuration - LoadConfiguration(); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to create default API key configuration"); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs deleted file mode 100644 index 6317607..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs +++ /dev/null @@ -1,329 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Manages TLS certificates for the LmxProxy service, including generation and validation - /// - public class TlsCertificateManager - { - private static readonly ILogger Logger = Log.ForContext(); - private readonly TlsConfiguration _tlsConfiguration; - - public TlsCertificateManager(TlsConfiguration tlsConfiguration) - { - _tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration)); - } - - /// - /// Checks TLS certificate status and creates new certificates if needed - /// - /// True if certificates are valid or were successfully created - public bool EnsureCertificatesValid() - { - if (!_tlsConfiguration.Enabled) - { - Logger.Information("TLS is disabled, skipping certificate check"); - return true; - } - - try - { - // Check if certificate files exist - bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath); - bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath); - - if (!certificateExists || !keyExists) - { - Logger.Warning("TLS certificate or key not found, generating new certificate"); - return GenerateNewCertificate(); - } - - // Check certificate expiration - if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath)) - { - Logger.Warning("TLS certificate is expiring within the next year, generating new certificate"); - return GenerateNewCertificate(); - } - - Logger.Information("TLS certificate is valid"); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Error checking TLS certificates"); - return false; - } - } - - /// - /// Checks if a certificate is expiring within the next year - /// - private bool IsCertificateExpiringSoon(string certificatePath) - { - try - { - string certPem = File.ReadAllText(certificatePath); - byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE"); - - using var cert = new X509Certificate2(certBytes); - DateTime expirationDate = cert.NotAfter; - double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays; - - Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)", - expirationDate, daysUntilExpiration); - - // Check if expiring within the next year (365 days) - return daysUntilExpiration <= 365; - } - catch (Exception ex) - { - Logger.Error(ex, "Error checking certificate expiration"); - // If we can't check expiration, assume it needs renewal - return true; - } - } - - /// - /// Generates a new self-signed certificate - /// - private bool GenerateNewCertificate() - { - try - { - Logger.Information("Generating new self-signed TLS certificate"); - - // Ensure directory exists - string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath); - if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir)) - { - Directory.CreateDirectory(certDir); - Logger.Information("Created certificate directory: {Directory}", certDir); - } - - // Generate a new self-signed certificate - using var rsa = RSA.Create(2048); - var request = new CertificateRequest( - "CN=LmxProxy, O=SCADA Bridge, C=US", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Add certificate extensions - request.CertificateExtensions.Add( - new X509BasicConstraintsExtension(false, false, 0, false)); - - request.CertificateExtensions.Add( - new X509KeyUsageExtension( - X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, - false)); - - request.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection - { - new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication - }, - false)); - - // Add Subject Alternative Names - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("localhost"); - sanBuilder.AddDnsName(Environment.MachineName); - sanBuilder.AddIpAddress(IPAddress.Loopback); - sanBuilder.AddIpAddress(IPAddress.IPv6Loopback); - request.CertificateExtensions.Add(sanBuilder.Build()); - - // Create the certificate with 2-year validity - DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1); - DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2); - - using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter); - - // Export certificate to PEM format - string certPem = ExportCertificateToPem(cert); - File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem); - Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath); - - // Export private key to PEM format - string keyPem = ExportPrivateKeyToPem(rsa); - File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem); - Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath); - - // If client CA path is specified and doesn't exist, create it - if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) && - !File.Exists(_tlsConfiguration.ClientCaCertificatePath)) - { - // For self-signed certificates, the CA cert is the same as the server cert - File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem); - Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath); - } - - Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to generate new TLS certificate"); - return false; - } - } - - /// - /// Exports a certificate to PEM format - /// - private static string ExportCertificateToPem(X509Certificate2 cert) - { - var builder = new StringBuilder(); - builder.AppendLine("-----BEGIN CERTIFICATE-----"); - builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), - Base64FormattingOptions.InsertLineBreaks)); - builder.AppendLine("-----END CERTIFICATE-----"); - return builder.ToString(); - } - - /// - /// Exports an RSA private key to PEM format - /// - private static string ExportPrivateKeyToPem(RSA rsa) - { - var builder = new StringBuilder(); - builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----"); - - // For .NET Framework 4.8, we need to use the older export method - RSAParameters parameters = rsa.ExportParameters(true); - byte[] keyBytes = EncodeRSAPrivateKey(parameters); - builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks)); - - builder.AppendLine("-----END RSA PRIVATE KEY-----"); - return builder.ToString(); - } - - /// - /// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8 - /// - private static byte[] EncodeRSAPrivateKey(RSAParameters parameters) - { - using (var stream = new MemoryStream()) - using (var writer = new BinaryWriter(stream)) - { - // Write version - writer.Write((byte)0x02); // INTEGER - writer.Write((byte)0x01); // Length - writer.Write((byte)0x00); // Version - - // Write modulus - WriteIntegerBytes(writer, parameters.Modulus); - - // Write public exponent - WriteIntegerBytes(writer, parameters.Exponent); - - // Write private exponent - WriteIntegerBytes(writer, parameters.D); - - // Write prime1 - WriteIntegerBytes(writer, parameters.P); - - // Write prime2 - WriteIntegerBytes(writer, parameters.Q); - - // Write exponent1 - WriteIntegerBytes(writer, parameters.DP); - - // Write exponent2 - WriteIntegerBytes(writer, parameters.DQ); - - // Write coefficient - WriteIntegerBytes(writer, parameters.InverseQ); - - byte[] innerBytes = stream.ToArray(); - - // Create SEQUENCE wrapper - using (var finalStream = new MemoryStream()) - using (var finalWriter = new BinaryWriter(finalStream)) - { - finalWriter.Write((byte)0x30); // SEQUENCE - WriteLength(finalWriter, innerBytes.Length); - finalWriter.Write(innerBytes); - return finalStream.ToArray(); - } - } - } - - private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes) - { - if (bytes == null) - { - bytes = new byte[] { 0 }; - } - - writer.Write((byte)0x02); // INTEGER - - if (bytes[0] >= 0x80) - { - // Add padding byte for positive number - WriteLength(writer, bytes.Length + 1); - writer.Write((byte)0x00); - writer.Write(bytes); - } - else - { - WriteLength(writer, bytes.Length); - writer.Write(bytes); - } - } - - private static void WriteLength(BinaryWriter writer, int length) - { - if (length < 0x80) - { - writer.Write((byte)length); - } - else if (length <= 0xFF) - { - writer.Write((byte)0x81); - writer.Write((byte)length); - } - else - { - writer.Write((byte)0x82); - writer.Write((byte)(length >> 8)); - writer.Write((byte)(length & 0xFF)); - } - } - - /// - /// Extracts bytes from PEM format - /// - private static byte[] GetBytesFromPem(string pem, string section) - { - string header = $"-----BEGIN {section}-----"; - string footer = $"-----END {section}-----"; - - int start = pem.IndexOf(header, StringComparison.Ordinal); - if (start < 0) - { - throw new InvalidOperationException($"PEM {section} header not found"); - } - - start += header.Length; - int end = pem.IndexOf(footer, start, StringComparison.Ordinal); - - if (end < 0) - { - throw new InvalidOperationException($"PEM {section} footer not found"); - } - - // Use Substring instead of range syntax for .NET Framework 4.8 compatibility - string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", ""); - return Convert.FromBase64String(base64); - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs deleted file mode 100644 index 8fbdaf0..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/HealthCheckService.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// Health check service for monitoring LmxProxy health - /// - public class HealthCheckService : IHealthCheck - { - private static readonly ILogger Logger = Log.ForContext(); - private readonly PerformanceMetrics _performanceMetrics; - - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - - public HealthCheckService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics) - { - _scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient)); - _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); - _performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics)); - } - - public Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var data = new Dictionary(); - - try - { - // Check SCADA connection - bool isConnected = _scadaClient.IsConnected; - ConnectionState connectionState = _scadaClient.ConnectionState; - data["scada_connected"] = isConnected; - data["scada_connection_state"] = connectionState.ToString(); - - // Get subscription statistics - SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats(); - data["total_clients"] = subscriptionStats.TotalClients; - data["total_tags"] = subscriptionStats.TotalTags; - - // Get performance metrics - IReadOnlyDictionary metrics = _performanceMetrics.GetAllMetrics(); - long totalOperations = 0L; - double averageSuccessRate = 0.0; - - foreach (OperationMetrics? metric in metrics.Values) - { - MetricsStatistics stats = metric.GetStatistics(); - totalOperations += stats.TotalCount; - averageSuccessRate += stats.SuccessRate; - } - - if (metrics.Count > 0) - { - averageSuccessRate /= metrics.Count; - } - - data["total_operations"] = totalOperations; - data["average_success_rate"] = averageSuccessRate; - - // Determine health status - if (!isConnected) - { - return Task.FromResult(HealthCheckResult.Unhealthy( - "SCADA client is not connected", - data: data)); - } - - if (averageSuccessRate < 0.5 && totalOperations > 100) - { - return Task.FromResult(HealthCheckResult.Degraded( - $"Low success rate: {averageSuccessRate:P}", - data: data)); - } - - if (subscriptionStats.TotalClients > 100) - { - return Task.FromResult(HealthCheckResult.Degraded( - $"High client count: {subscriptionStats.TotalClients}", - data: data)); - } - - return Task.FromResult(HealthCheckResult.Healthy( - "LmxProxy is healthy", - data)); - } - catch (Exception ex) - { - Logger.Error(ex, "Health check failed"); - data["error"] = ex.Message; - - return Task.FromResult(HealthCheckResult.Unhealthy( - "Health check threw an exception", - ex, - data)); - } - } - } - - /// - /// Detailed health check that performs additional connectivity tests - /// - public class DetailedHealthCheckService : IHealthCheck - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly string _testTagAddress; - - public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "System.Heartbeat") - { - _scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient)); - _testTagAddress = testTagAddress; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var data = new Dictionary(); - - try - { - // Basic connectivity check - if (!_scadaClient.IsConnected) - { - data["connected"] = false; - return HealthCheckResult.Unhealthy("SCADA client is not connected", data: data); - } - - data["connected"] = true; - - // Try to read a test tag - try - { - Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken); - data["test_tag_quality"] = vtq.Quality.ToString(); - data["test_tag_timestamp"] = vtq.Timestamp; - - if (vtq.Quality != Quality.Good) - { - return HealthCheckResult.Degraded( - $"Test tag quality is {vtq.Quality}", - data: data); - } - - // Check if timestamp is recent (within last 5 minutes) - TimeSpan age = DateTime.UtcNow - vtq.Timestamp; - if (age > TimeSpan.FromMinutes(5)) - { - data["timestamp_age_minutes"] = age.TotalMinutes; - return HealthCheckResult.Degraded( - $"Test tag timestamp is stale ({age.TotalMinutes:F1} minutes old)", - data: data); - } - } - catch (Exception readEx) - { - data["test_tag_error"] = readEx.Message; - return HealthCheckResult.Degraded( - "Could not read test tag", - data: data); - } - - return HealthCheckResult.Healthy("All checks passed", data); - } - catch (Exception ex) - { - Logger.Error(ex, "Detailed health check failed"); - data["error"] = ex.Message; - - return HealthCheckResult.Unhealthy( - "Health check threw an exception", - ex, - data); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs deleted file mode 100644 index c0c2525..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/PerformanceMetrics.cs +++ /dev/null @@ -1,213 +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.LmxProxy.Host.Services -{ - /// - /// Provides performance metrics tracking for LmxProxy operations - /// - public class PerformanceMetrics : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly ConcurrentDictionary _metrics = new(); - private readonly Timer _reportingTimer; - private bool _disposed; - - /// - /// Initializes a new instance of the PerformanceMetrics class - /// - public PerformanceMetrics() - { - // Report metrics every minute - _reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - _reportingTimer?.Dispose(); - ReportMetrics(null); // Final report - } - - /// - /// Records the execution time of an operation - /// - public void RecordOperation(string operationName, TimeSpan duration, bool success = true) - { - OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics()); - metrics.Record(duration, success); - } - - /// - /// Creates a timing scope for measuring operation duration - /// - public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName); - - /// - /// Gets current metrics for a specific operation - /// - public OperationMetrics? GetMetrics(string operationName) => - _metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null; - - /// - /// Gets all current metrics - /// - public IReadOnlyDictionary GetAllMetrics() => - _metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - /// - /// Gets statistics for all operations - /// - public Dictionary GetStatistics() => - _metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics()); - - private void ReportMetrics(object? state) - { - foreach (KeyValuePair kvp in _metrics) - { - MetricsStatistics stats = kvp.Value.GetStatistics(); - if (stats.TotalCount > 0) - { - Logger.Information( - "Performance Metrics - {Operation}: Count={Count}, Success={SuccessRate:P}, " + - "Avg={AverageMs:F2}ms, Min={MinMs:F2}ms, Max={MaxMs:F2}ms, P95={P95Ms:F2}ms", - kvp.Key, - stats.TotalCount, - stats.SuccessRate, - stats.AverageMilliseconds, - stats.MinMilliseconds, - stats.MaxMilliseconds, - stats.Percentile95Milliseconds); - } - } - } - - /// - /// Timing scope for automatic duration measurement - /// - public interface ITimingScope : IDisposable - { - void SetSuccess(bool success); - } - - private class TimingScope : ITimingScope - { - private readonly PerformanceMetrics _metrics; - private readonly string _operationName; - private readonly Stopwatch _stopwatch; - private bool _disposed; - private bool _success = true; - - public TimingScope(PerformanceMetrics metrics, string operationName) - { - _metrics = metrics; - _operationName = operationName; - _stopwatch = Stopwatch.StartNew(); - } - - public void SetSuccess(bool success) => _success = success; - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - _stopwatch.Stop(); - _metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success); - } - } - } - - /// - /// Metrics for a specific operation - /// - public class OperationMetrics - { - private readonly List _durations = new(); - private readonly object _lock = new(); - private double _maxMilliseconds; - private double _minMilliseconds = double.MaxValue; - private long _successCount; - private long _totalCount; - private double _totalMilliseconds; - - public void Record(TimeSpan duration, bool success) - { - lock (_lock) - { - double ms = duration.TotalMilliseconds; - _durations.Add(ms); - _totalCount++; - if (success) - { - _successCount++; - } - - _totalMilliseconds += ms; - _minMilliseconds = Math.Min(_minMilliseconds, ms); - _maxMilliseconds = Math.Max(_maxMilliseconds, ms); - - // Keep only last 1000 samples for percentile calculation - if (_durations.Count > 1000) - { - _durations.RemoveAt(0); - } - } - } - - public MetricsStatistics GetStatistics() - { - lock (_lock) - { - if (_totalCount == 0) - { - return new MetricsStatistics(); - } - - var sortedDurations = _durations.OrderBy(d => d).ToList(); - int p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1; - - return new MetricsStatistics - { - TotalCount = _totalCount, - SuccessCount = _successCount, - SuccessRate = _successCount / (double)_totalCount, - AverageMilliseconds = _totalMilliseconds / _totalCount, - MinMilliseconds = _minMilliseconds == double.MaxValue ? 0 : _minMilliseconds, - MaxMilliseconds = _maxMilliseconds, - Percentile95Milliseconds = sortedDurations.Count > 0 ? sortedDurations[Math.Max(0, p95Index)] : 0 - }; - } - } - } - - /// - /// Statistics for an operation - /// - public class MetricsStatistics - { - public long TotalCount { get; set; } - public long SuccessCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - public double Percentile95Milliseconds { get; set; } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs deleted file mode 100644 index 0c08723..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/RetryPolicies.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Threading.Tasks; -using Polly; -using Polly.Timeout; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// Provides retry policies for resilient operations - /// - public static class RetryPolicies - { - private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies)); - - /// - /// Creates a retry policy with exponential backoff for read operations - /// - public static IAsyncPolicy CreateReadPolicy() - { - return Policy - .Handle(ex => !(ex is ArgumentException || ex is InvalidOperationException)) - .WaitAndRetryAsync( - 3, - retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)), - (outcome, timespan, retryCount, context) => - { - Exception? exception = outcome.Exception; - Logger.Warning(exception, - "Read operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}", - retryCount, - timespan.TotalMilliseconds, - context.ContainsKey("Operation") ? context["Operation"] : "Unknown"); - }); - } - - /// - /// Creates a retry policy with exponential backoff for write operations - /// - public static IAsyncPolicy CreateWritePolicy() - { - return Policy - .Handle(ex => !(ex is ArgumentException || ex is InvalidOperationException)) - .WaitAndRetryAsync( - 3, - retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), - (exception, timespan, retryCount, context) => - { - Logger.Warning(exception, - "Write operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}", - retryCount, - timespan.TotalMilliseconds, - context.ContainsKey("Operation") ? context["Operation"] : "Unknown"); - }); - } - - /// - /// Creates a retry policy for connection operations with longer delays - /// - public static IAsyncPolicy CreateConnectionPolicy() - { - return Policy - .Handle() - .WaitAndRetryAsync( - 5, - retryAttempt => - { - // 2s, 4s, 8s, 16s, 32s - var delay = TimeSpan.FromSeconds(Math.Min(32, Math.Pow(2, retryAttempt))); - return delay; - }, - (exception, timespan, retryCount, context) => - { - Logger.Warning(exception, - "Connection retry {RetryCount} after {DelayMs}ms", - retryCount, - timespan.TotalMilliseconds); - }); - } - - /// - /// Creates a circuit breaker policy for protecting against repeated failures - /// - public static IAsyncPolicy CreateCircuitBreakerPolicy() - { - return Policy - .Handle() - .CircuitBreakerAsync( - 5, - TimeSpan.FromSeconds(30), - (result, timespan) => - { - Logger.Error(result.Exception, - "Circuit breaker opened for {BreakDurationSeconds}s due to repeated failures", - timespan.TotalSeconds); - }, - () => { Logger.Information("Circuit breaker reset - resuming normal operations"); }, - () => { Logger.Information("Circuit breaker half-open - testing operation"); }); - } - - /// - /// Creates a combined policy with retry and circuit breaker - /// - public static IAsyncPolicy CreateCombinedPolicy() - { - IAsyncPolicy retry = CreateReadPolicy(); - IAsyncPolicy circuitBreaker = CreateCircuitBreakerPolicy(); - - // Wrap retry around circuit breaker - // This means retry happens first, and if all retries fail, it counts toward the circuit breaker - return Policy.WrapAsync(retry, circuitBreaker); - } - - /// - /// Creates a timeout policy for operations - /// - public static IAsyncPolicy CreateTimeoutPolicy(TimeSpan timeout) - { - return Policy - .TimeoutAsync( - timeout, - TimeoutStrategy.Pessimistic, - async (context, timespan, task) => - { - Logger.Warning( - "Operation timed out after {TimeoutMs}ms. Operation: {Operation}", - timespan.TotalMilliseconds, - context.ContainsKey("Operation") ? context["Operation"] : "Unknown"); - - if (task != null) - { - try - { - await task; - } - catch - { - // Ignore exceptions from the timed-out task - } - } - }); - } - - /// - /// Creates a bulkhead policy to limit concurrent operations - /// - public static IAsyncPolicy CreateBulkheadPolicy(int maxParallelization, int maxQueuingActions = 100) - { - return Policy - .BulkheadAsync( - maxParallelization, - maxQueuingActions, - context => - { - Logger.Warning( - "Bulkhead rejected operation. Max parallelization: {MaxParallel}, Queue: {MaxQueue}", - maxParallelization, - maxQueuingActions); - return Task.CompletedTask; - }); - } - } - - /// - /// Extension methods for applying retry policies - /// - public static class RetryPolicyExtensions - { - /// - /// Executes an operation with retry policy - /// - public static async Task ExecuteWithRetryAsync( - this IAsyncPolicy policy, - Func> operation, - string operationName) - { - var context = new Context { ["Operation"] = operationName }; - return await policy.ExecuteAsync(async ctx => await operation(), context); - } - - /// - /// Executes an operation with retry policy (non-generic) - /// - public static async Task ExecuteWithRetryAsync( - this IAsyncPolicy policy, - Func operation, - string operationName) - { - var context = new Context { ["Operation"] = operationName }; - await policy.ExecuteAsync(async ctx => await operation(), context); - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs deleted file mode 100644 index f91fce1..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SessionManager.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// Manages client sessions for the gRPC service. - /// Tracks active sessions with unique session IDs. - /// - public class SessionManager : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly ConcurrentDictionary _sessions = new(); - private bool _disposed; - - /// - /// Gets the number of active sessions. - /// - public int ActiveSessionCount => _sessions.Count; - - /// - /// Creates a new session for a client. - /// - /// The client identifier. - /// The API key used for authentication (optional). - /// The session ID for the new session. - /// Thrown if the manager is disposed. - public string CreateSession(string clientId, string apiKey = null) - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(SessionManager)); - } - - var sessionId = Guid.NewGuid().ToString("N"); - var sessionInfo = new SessionInfo - { - SessionId = sessionId, - ClientId = clientId ?? string.Empty, - ApiKey = apiKey ?? string.Empty, - ConnectedAt = DateTime.UtcNow, - LastActivity = DateTime.UtcNow - }; - - _sessions[sessionId] = sessionInfo; - - Logger.Information("Created session {SessionId} for client {ClientId}", sessionId, clientId); - - return sessionId; - } - - /// - /// Validates a session ID and updates the last activity timestamp. - /// - /// The session ID to validate. - /// True if the session is valid; otherwise, false. - public bool ValidateSession(string sessionId) - { - if (_disposed) - { - return false; - } - - if (string.IsNullOrEmpty(sessionId)) - { - return false; - } - - if (_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo)) - { - sessionInfo.LastActivity = DateTime.UtcNow; - return true; - } - - return false; - } - - /// - /// Gets the session information for a session ID. - /// - /// The session ID. - /// The session information, or null if not found. - public SessionInfo GetSession(string sessionId) - { - if (_disposed || string.IsNullOrEmpty(sessionId)) - { - return null; - } - - _sessions.TryGetValue(sessionId, out SessionInfo sessionInfo); - return sessionInfo; - } - - /// - /// Terminates a session. - /// - /// The session ID to terminate. - /// True if the session was terminated; otherwise, false. - public bool TerminateSession(string sessionId) - { - if (_disposed || string.IsNullOrEmpty(sessionId)) - { - return false; - } - - if (_sessions.TryRemove(sessionId, out SessionInfo sessionInfo)) - { - Logger.Information("Terminated session {SessionId} for client {ClientId}", sessionId, sessionInfo.ClientId); - return true; - } - - return false; - } - - /// - /// Gets all active sessions. - /// - /// A list of all active session information. - public IReadOnlyList GetAllSessions() - { - return _sessions.Values.ToList(); - } - - /// - /// Disposes the session manager and clears all sessions. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - var count = _sessions.Count; - _sessions.Clear(); - - Logger.Information("SessionManager disposed, cleared {Count} sessions", count); - } - } - - /// - /// Contains information about a client session. - /// - public class SessionInfo - { - /// - /// Gets or sets the unique session identifier. - /// - public string SessionId { get; set; } = string.Empty; - - /// - /// Gets or sets the client identifier. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// Gets or sets the API key used for this session. - /// - public string ApiKey { get; set; } = string.Empty; - - /// - /// Gets or sets the time when the session was created. - /// - public DateTime ConnectedAt { get; set; } - - /// - /// Gets or sets the time of the last activity on this session. - /// - public DateTime LastActivity { get; set; } - - /// - /// Gets the connected time as UTC ticks for the gRPC response. - /// - public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs deleted file mode 100644 index 95d9042..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusReportService.cs +++ /dev/null @@ -1,433 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// Service for collecting and formatting status information from various LmxProxy components - /// - public class StatusReportService - { - private static readonly ILogger Logger = Log.ForContext(); - private readonly DetailedHealthCheckService? _detailedHealthCheckService; - private readonly HealthCheckService _healthCheckService; - private readonly PerformanceMetrics _performanceMetrics; - - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - - /// - /// Initializes a new instance of the StatusReportService class - /// - public StatusReportService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics, - HealthCheckService healthCheckService, - DetailedHealthCheckService? detailedHealthCheckService = null) - { - _scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient)); - _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); - _performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics)); - _healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService)); - _detailedHealthCheckService = detailedHealthCheckService; - } - - /// - /// Generates a comprehensive status report as HTML - /// - public async Task GenerateHtmlReportAsync() - { - try - { - StatusData statusData = await CollectStatusDataAsync(); - return GenerateHtmlFromStatusData(statusData); - } - catch (Exception ex) - { - Logger.Error(ex, "Error generating HTML status report"); - return GenerateErrorHtml(ex); - } - } - - /// - /// Generates a comprehensive status report as JSON - /// - public async Task GenerateJsonReportAsync() - { - try - { - StatusData statusData = await CollectStatusDataAsync(); - return JsonSerializer.Serialize(statusData, new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - } - catch (Exception ex) - { - Logger.Error(ex, "Error generating JSON status report"); - return JsonSerializer.Serialize(new { error = ex.Message }, new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - } - } - - /// - /// Checks if the service is healthy - /// - public async Task IsHealthyAsync() - { - try - { - HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext()); - return healthResult.Status == HealthStatus.Healthy; - } - catch (Exception ex) - { - Logger.Error(ex, "Error checking health status"); - return false; - } - } - - /// - /// Collects status data from all components - /// - private async Task CollectStatusDataAsync() - { - var statusData = new StatusData - { - Timestamp = DateTime.UtcNow, - ServiceName = "ZB.MOM.WW.LmxProxy.Host", - Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown" - }; - - // Collect connection status - statusData.Connection = new ConnectionStatus - { - IsConnected = _scadaClient.IsConnected, - State = _scadaClient.ConnectionState.ToString(), - NodeName = "N/A", // Could be extracted from configuration if needed - GalaxyName = "N/A" // Could be extracted from configuration if needed - }; - - // Collect subscription statistics - SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats(); - statusData.Subscriptions = new SubscriptionStatus - { - TotalClients = subscriptionStats.TotalClients, - TotalTags = subscriptionStats.TotalTags, - ActiveSubscriptions = subscriptionStats.TotalTags // Assuming same for simplicity - }; - - // Collect performance metrics - Dictionary perfMetrics = _performanceMetrics.GetStatistics(); - statusData.Performance = new PerformanceStatus - { - TotalOperations = perfMetrics.Values.Sum(m => m.TotalCount), - AverageSuccessRate = perfMetrics.Count > 0 ? perfMetrics.Values.Average(m => m.SuccessRate) : 1.0, - Operations = perfMetrics.ToDictionary( - kvp => kvp.Key, - kvp => new OperationStatus - { - TotalCount = kvp.Value.TotalCount, - SuccessRate = kvp.Value.SuccessRate, - AverageMilliseconds = kvp.Value.AverageMilliseconds, - MinMilliseconds = kvp.Value.MinMilliseconds, - MaxMilliseconds = kvp.Value.MaxMilliseconds - }) - }; - - // Collect health check results - try - { - HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext()); - statusData.Health = new HealthInfo - { - Status = healthResult.Status.ToString(), - Description = healthResult.Description ?? "", - Data = healthResult.Data?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") ?? - new Dictionary() - }; - - // Collect detailed health check if available - if (_detailedHealthCheckService != null) - { - HealthCheckResult detailedHealthResult = - await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext()); - statusData.DetailedHealth = new HealthInfo - { - Status = detailedHealthResult.Status.ToString(), - Description = detailedHealthResult.Description ?? "", - Data = detailedHealthResult.Data?.ToDictionary(kvp => kvp.Key, - kvp => kvp.Value?.ToString() ?? "") ?? new Dictionary() - }; - } - } - catch (Exception ex) - { - Logger.Error(ex, "Error collecting health check data"); - statusData.Health = new HealthInfo - { - Status = "Error", - Description = $"Health check failed: {ex.Message}", - Data = new Dictionary() - }; - } - - return statusData; - } - - /// - /// Generates HTML from status data - /// - private static string GenerateHtmlFromStatusData(StatusData statusData) - { - var html = new StringBuilder(); - - html.AppendLine(""); - html.AppendLine(""); - html.AppendLine(""); - html.AppendLine(" LmxProxy Status"); - html.AppendLine(" "); - html.AppendLine(" "); - html.AppendLine(" "); - html.AppendLine(" "); - html.AppendLine(""); - html.AppendLine(""); - html.AppendLine("
"); - - // Header - html.AppendLine("
"); - html.AppendLine("

LmxProxy Status Dashboard

"); - html.AppendLine($"

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

"); - html.AppendLine("
"); - - html.AppendLine("
"); - - // Connection Status Card - string connectionClass = statusData.Connection.IsConnected ? "status-connected" : "status-disconnected"; - string connectionStatusText = statusData.Connection.IsConnected ? "Connected" : "Disconnected"; - string connectionStatusClass = statusData.Connection.IsConnected ? "status-healthy" : "status-error"; - - html.AppendLine($"
"); - html.AppendLine("

MxAccess Connection

"); - html.AppendLine( - $"

Status: {connectionStatusText}

"); - html.AppendLine( - $"

State: {statusData.Connection.State}

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

Subscriptions

"); - html.AppendLine( - $"

Total Clients: {statusData.Subscriptions.TotalClients}

"); - html.AppendLine( - $"

Total Tags: {statusData.Subscriptions.TotalTags}

"); - html.AppendLine( - $"

Active Subscriptions: {statusData.Subscriptions.ActiveSubscriptions}

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

Performance

"); - html.AppendLine( - $"

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

"); - html.AppendLine( - $"

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

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

Health Status

"); - html.AppendLine( - $"

Status: {statusData.Health.Status}

"); - html.AppendLine( - $"

Description: {statusData.Health.Description}

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

Operation Performance Metrics

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

LmxProxy Status Dashboard

-
-

Error Loading Status

-

An error occurred while collecting status information:

-

{ex.Message}

-
-
- Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC -
-
- -"; - } - } - - /// - /// Data structure for holding complete status information - /// - public class StatusData - { - public DateTime Timestamp { get; set; } - public string ServiceName { get; set; } = ""; - public string Version { get; set; } = ""; - public ConnectionStatus Connection { get; set; } = new(); - public SubscriptionStatus Subscriptions { get; set; } = new(); - public PerformanceStatus Performance { get; set; } = new(); - public HealthInfo Health { get; set; } = new(); - public HealthInfo? DetailedHealth { get; set; } - } - - /// - /// Connection status information - /// - public class ConnectionStatus - { - public bool IsConnected { get; set; } - public string State { get; set; } = ""; - public string NodeName { get; set; } = ""; - public string GalaxyName { get; set; } = ""; - } - - /// - /// Subscription status information - /// - public class SubscriptionStatus - { - public int TotalClients { get; set; } - public int TotalTags { get; set; } - public int ActiveSubscriptions { get; set; } - } - - /// - /// Performance status information - /// - public class PerformanceStatus - { - public long TotalOperations { get; set; } - public double AverageSuccessRate { get; set; } - public Dictionary Operations { get; set; } = new(); - } - - /// - /// Individual operation status - /// - public class OperationStatus - { - public long TotalCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - } - - /// - /// Health check status information - /// - public class HealthInfo - { - public string Status { get; set; } = ""; - public string Description { get; set; } = ""; - public Dictionary Data { get; set; } = new(); - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs deleted file mode 100644 index 7c0d41e..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/StatusWebServer.cs +++ /dev/null @@ -1,315 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// HTTP web server that serves status information for the LmxProxy service - /// - public class StatusWebServer : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly WebServerConfiguration _configuration; - private readonly StatusReportService _statusReportService; - private CancellationTokenSource? _cancellationTokenSource; - private bool _disposed; - private HttpListener? _httpListener; - private Task? _listenerTask; - - /// - /// Initializes a new instance of the StatusWebServer class - /// - /// Web server configuration - /// Service for collecting status information - public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService)); - } - - /// - /// Disposes the web server and releases resources - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - Stop(); - - _cancellationTokenSource?.Dispose(); - _httpListener?.Close(); - } - - /// - /// Starts the HTTP web server - /// - /// True if started successfully, false otherwise - public bool Start() - { - try - { - if (!_configuration.Enabled) - { - Logger.Information("Status web server is disabled"); - return true; - } - - Logger.Information("Starting status web server on port {Port}", _configuration.Port); - - _httpListener = new HttpListener(); - - // Configure the URL prefix - string prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/"; - if (!prefix.EndsWith("/")) - { - prefix += "/"; - } - - _httpListener.Prefixes.Add(prefix); - _httpListener.Start(); - - _cancellationTokenSource = new CancellationTokenSource(); - _listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token)); - - Logger.Information("Status web server started successfully on {Prefix}", prefix); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to start status web server"); - return false; - } - } - - /// - /// Stops the HTTP web server - /// - /// True if stopped successfully, false otherwise - public bool Stop() - { - try - { - if (!_configuration.Enabled || _httpListener == null) - { - return true; - } - - Logger.Information("Stopping status web server"); - - _cancellationTokenSource?.Cancel(); - - if (_listenerTask != null) - { - try - { - _listenerTask.Wait(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - Logger.Warning(ex, "Error waiting for listener task to complete"); - } - } - - _httpListener?.Stop(); - _httpListener?.Close(); - - Logger.Information("Status web server stopped successfully"); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Error stopping status web server"); - return false; - } - } - - /// - /// Main request handling loop - /// - private async Task HandleRequestsAsync(CancellationToken cancellationToken) - { - Logger.Information("Status web server listener started"); - - while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening) - { - try - { - HttpListenerContext? context = await _httpListener.GetContextAsync(); - - // Handle request asynchronously without waiting - _ = Task.Run(async () => - { - try - { - await HandleRequestAsync(context); - } - catch (Exception ex) - { - Logger.Error(ex, "Error handling HTTP request from {RemoteEndPoint}", - context.Request.RemoteEndPoint); - } - }, cancellationToken); - } - catch (ObjectDisposedException) - { - // Expected when stopping the listener - break; - } - catch (HttpListenerException ex) when (ex.ErrorCode == 995) // ERROR_OPERATION_ABORTED - { - // Expected when stopping the listener - break; - } - catch (Exception ex) - { - Logger.Error(ex, "Error in request listener loop"); - - // Brief delay before continuing to avoid tight error loops - try - { - await Task.Delay(1000, cancellationToken); - } - catch (OperationCanceledException) - { - break; - } - } - } - - Logger.Information("Status web server listener stopped"); - } - - /// - /// Handles a single HTTP request - /// - private async Task HandleRequestAsync(HttpListenerContext context) - { - HttpListenerRequest? request = context.Request; - HttpListenerResponse response = context.Response; - - try - { - Logger.Debug("Handling {Method} request to {Url} from {RemoteEndPoint}", - request.HttpMethod, request.Url?.AbsolutePath, request.RemoteEndPoint); - - // Only allow GET requests - if (request.HttpMethod != "GET") - { - response.StatusCode = 405; // Method Not Allowed - response.StatusDescription = "Method Not Allowed"; - await WriteResponseAsync(response, "Only GET requests are supported", "text/plain"); - return; - } - - string path = request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/"; - - switch (path) - { - case "/": - await HandleStatusPageAsync(response); - break; - - case "/api/status": - await HandleStatusApiAsync(response); - break; - - case "/api/health": - await HandleHealthApiAsync(response); - break; - - default: - response.StatusCode = 404; // Not Found - response.StatusDescription = "Not Found"; - await WriteResponseAsync(response, "Resource not found", "text/plain"); - break; - } - } - catch (Exception ex) - { - Logger.Error(ex, "Error handling HTTP request"); - - try - { - response.StatusCode = 500; // Internal Server Error - response.StatusDescription = "Internal Server Error"; - await WriteResponseAsync(response, "Internal server error", "text/plain"); - } - catch (Exception responseEx) - { - Logger.Error(responseEx, "Error writing error response"); - } - } - finally - { - try - { - response.Close(); - } - catch (Exception ex) - { - Logger.Warning(ex, "Error closing HTTP response"); - } - } - } - - /// - /// Handles the main status page (HTML) - /// - private async Task HandleStatusPageAsync(HttpListenerResponse response) - { - string statusHtml = await _statusReportService.GenerateHtmlReportAsync(); - await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8"); - } - - /// - /// Handles the status API endpoint (JSON) - /// - private async Task HandleStatusApiAsync(HttpListenerResponse response) - { - string statusJson = await _statusReportService.GenerateJsonReportAsync(); - await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8"); - } - - /// - /// Handles the health API endpoint (simple text) - /// - private async Task HandleHealthApiAsync(HttpListenerResponse response) - { - bool isHealthy = await _statusReportService.IsHealthyAsync(); - string healthText = isHealthy ? "OK" : "UNHEALTHY"; - response.StatusCode = isHealthy ? 200 : 503; // Service Unavailable if unhealthy - await WriteResponseAsync(response, healthText, "text/plain"); - } - - /// - /// Writes a response to the HTTP context - /// - private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType) - { - response.ContentType = contentType; - response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); - response.Headers.Add("Pragma", "no-cache"); - response.Headers.Add("Expires", "0"); - - byte[] buffer = Encoding.UTF8.GetBytes(content); - response.ContentLength64 = buffer.Length; - - using (Stream? output = response.OutputStream) - { - await output.WriteAsync(buffer, 0, buffer.Length); - } - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs deleted file mode 100644 index 49921eb..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Services/SubscriptionManager.cs +++ /dev/null @@ -1,535 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Services -{ - /// - /// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client - /// statistics. - /// - public class SubscriptionManager : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - // Configuration for channel buffering - private readonly int _channelCapacity; - private readonly BoundedChannelFullMode _channelFullMode; - private readonly ConcurrentDictionary _clientSubscriptions = new(); - private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion); - - private readonly IScadaClient _scadaClient; - private readonly ConcurrentDictionary _tagSubscriptions = new(); - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The SCADA client to use for subscriptions. - /// The subscription configuration. - /// - /// Thrown if or - /// is null. - /// - public SubscriptionManager(IScadaClient scadaClient, SubscriptionConfiguration configuration) - { - _scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient)); - SubscriptionConfiguration configuration1 = - configuration ?? throw new ArgumentNullException(nameof(configuration)); - - _channelCapacity = configuration1.ChannelCapacity; - _channelFullMode = ParseChannelFullMode(configuration1.ChannelFullMode); - - // Subscribe to connection state changes - _scadaClient.ConnectionStateChanged += OnConnectionStateChanged; - - Logger.Information("SubscriptionManager initialized with channel capacity: {Capacity}, full mode: {Mode}", - _channelCapacity, _channelFullMode); - } - - /// - /// Disposes the , unsubscribing all clients and cleaning up resources. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - Logger.Information("Disposing SubscriptionManager"); - - // Unsubscribe from connection state changes - _scadaClient.ConnectionStateChanged -= OnConnectionStateChanged; - - // Unsubscribe all clients - var clientIds = _clientSubscriptions.Keys.ToList(); - foreach (string? clientId in clientIds) - { - UnsubscribeClient(clientId); - } - - _clientSubscriptions.Clear(); - _tagSubscriptions.Clear(); - - // Dispose the lock - _lock?.Dispose(); - } - - /// - /// Gets the number of active client subscriptions. - /// - public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count; - - /// - /// Parses the channel full mode string to . - /// - /// The mode string. - /// The parsed value. - private static BoundedChannelFullMode ParseChannelFullMode(string mode) - { - return mode?.ToUpperInvariant() switch - { - "DROPOLDEST" => BoundedChannelFullMode.DropOldest, - "DROPNEWEST" => BoundedChannelFullMode.DropNewest, - "WAIT" => BoundedChannelFullMode.Wait, - _ => BoundedChannelFullMode.DropOldest // Default - }; - } - - /// - /// Creates a new subscription for a client to a set of tag addresses. - /// - /// The client identifier. - /// The tag addresses to subscribe to. - /// Optional cancellation token. - /// A channel for receiving tag updates. - /// Thrown if the manager is disposed. - public async Task> SubscribeAsync( - string clientId, - IEnumerable addresses, - CancellationToken ct = default) - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(SubscriptionManager)); - } - - var addressList = addresses.ToList(); - Logger.Information("Client {ClientId} subscribing to {Count} tags", clientId, addressList.Count); - - // Create a bounded channel for this client with buffering - var channel = Channel.CreateBounded<(string address, Vtq vtq)>(new BoundedChannelOptions(_channelCapacity) - { - FullMode = _channelFullMode, - SingleReader = true, - SingleWriter = false, - AllowSynchronousContinuations = false - }); - - Logger.Debug("Created bounded channel for client {ClientId} with capacity {Capacity}", clientId, - _channelCapacity); - - var clientSubscription = new ClientSubscription - { - ClientId = clientId, - Channel = channel, - Addresses = new HashSet(addressList), - CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct) - }; - - _clientSubscriptions[clientId] = clientSubscription; - - // Subscribe to each tag - foreach (string? address in addressList) - { - await SubscribeToTagAsync(address, clientId); - } - - // Handle client disconnection - clientSubscription.CancellationTokenSource.Token.Register(() => - { - Logger.Information("Client {ClientId} disconnected, cleaning up subscriptions", clientId); - UnsubscribeClient(clientId); - }); - - return channel; - } - - /// - /// Unsubscribes a client from all tags and cleans up resources. - /// - /// The client identifier. - public void UnsubscribeClient(string clientId) - { - if (_clientSubscriptions.TryRemove(clientId, out ClientSubscription? clientSubscription)) - { - Logger.Information( - "Unsubscribing client {ClientId} from {Count} tags. Stats: Delivered={Delivered}, Dropped={Dropped}", - clientId, clientSubscription.Addresses.Count, - clientSubscription.DeliveredMessageCount, clientSubscription.DroppedMessageCount); - - _lock.EnterWriteLock(); - try - { - foreach (string? address in clientSubscription.Addresses) - { - if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription)) - { - tagSubscription.ClientIds.Remove(clientId); - - // If no more clients are subscribed to this tag, unsubscribe from SCADA - if (tagSubscription.ClientIds.Count == 0) - { - Logger.Information( - "No more clients subscribed to {Address}, removing SCADA subscription", address); - - _tagSubscriptions.TryRemove(address, out _); - - // Dispose the SCADA subscription - Task.Run(async () => - { - try - { - if (tagSubscription.ScadaSubscription != null) - { - await tagSubscription.ScadaSubscription.DisposeAsync(); - Logger.Debug("Successfully disposed SCADA subscription for {Address}", - address); - } - } - catch (Exception ex) - { - Logger.Error(ex, "Error disposing SCADA subscription for {Address}", address); - } - }); - } - else - { - Logger.Debug( - "Client {ClientId} removed from {Address} subscription (remaining clients: {Count})", - clientId, address, tagSubscription.ClientIds.Count); - } - } - } - } - finally - { - _lock.ExitWriteLock(); - } - - // Complete the channel - clientSubscription.Channel.Writer.TryComplete(); - clientSubscription.CancellationTokenSource.Dispose(); - } - } - - /// - /// Subscribes a client to a tag address, creating a new SCADA subscription if needed. - /// - /// The tag address. - /// The client identifier. - private async Task SubscribeToTagAsync(string address, string clientId) - { - bool needsSubscription; - TagSubscription? tagSubscription; - - _lock.EnterWriteLock(); - try - { - if (_tagSubscriptions.TryGetValue(address, out TagSubscription? existingSubscription)) - { - // Tag is already subscribed, just add this client - existingSubscription.ClientIds.Add(clientId); - Logger.Debug( - "Client {ClientId} added to existing subscription for {Address} (total clients: {Count})", - clientId, address, existingSubscription.ClientIds.Count); - return; - } - - // Create new tag subscription and reserve the spot - tagSubscription = new TagSubscription - { - Address = address, - ClientIds = new HashSet { clientId } - }; - _tagSubscriptions[address] = tagSubscription; - needsSubscription = true; - } - finally - { - _lock.ExitWriteLock(); - } - - if (needsSubscription && tagSubscription != null) - { - // Subscribe to SCADA outside of lock to avoid blocking - Logger.Debug("Creating new SCADA subscription for {Address}", address); - - try - { - IAsyncDisposable scadaSubscription = await _scadaClient.SubscribeAsync( - new[] { address }, - (addr, vtq) => OnTagValueChanged(addr, vtq), - CancellationToken.None); - - _lock.EnterWriteLock(); - try - { - tagSubscription.ScadaSubscription = scadaSubscription; - } - finally - { - _lock.ExitWriteLock(); - } - - Logger.Information("Successfully subscribed to {Address} for client {ClientId}", address, clientId); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to subscribe to {Address}", address); - - // Remove the failed subscription - _lock.EnterWriteLock(); - try - { - _tagSubscriptions.TryRemove(address, out _); - } - finally - { - _lock.ExitWriteLock(); - } - - throw; - } - } - } - - /// - /// Handles tag value changes and delivers updates to all subscribed clients. - /// - /// The tag address. - /// The value, timestamp, and quality. - private void OnTagValueChanged(string address, Vtq vtq) - { - Logger.Debug("Tag value changed: {Address} = {Vtq}", address, vtq); - - _lock.EnterReadLock(); - try - { - if (!_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription)) - { - Logger.Warning("Received update for untracked tag {Address}", address); - return; - } - - // Send update to all subscribed clients - // Use the existing collection directly without ToList() since we're in a read lock - foreach (string? clientId in tagSubscription.ClientIds) - { - if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription)) - { - try - { - if (!clientSubscription.Channel.Writer.TryWrite((address, vtq))) - { - // Channel is full - with DropOldest mode, this should rarely happen - Logger.Warning( - "Channel full for client {ClientId}, dropping message for {Address}. Consider increasing buffer size.", - clientId, address); - clientSubscription.DroppedMessageCount++; - } - else - { - clientSubscription.DeliveredMessageCount++; - } - } - catch (InvalidOperationException ex) when (ex.Message.Contains("closed")) - { - Logger.Debug("Channel closed for client {ClientId}, removing subscription", clientId); - // Schedule cleanup of disconnected client - Task.Run(() => UnsubscribeClient(clientId)); - } - catch (Exception ex) - { - Logger.Error(ex, "Error sending update to client {ClientId}", clientId); - } - } - } - } - finally - { - _lock.ExitReadLock(); - } - } - - /// - /// Gets current subscription statistics for all clients and tags. - /// - /// A object containing statistics. - public virtual SubscriptionStats GetSubscriptionStats() - { - _lock.EnterReadLock(); - try - { - var tagClientCounts = _tagSubscriptions.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value.ClientIds.Count); - - var clientStats = _clientSubscriptions.ToDictionary( - kvp => kvp.Key, - kvp => new ClientStats - { - SubscribedTags = kvp.Value.Addresses.Count, - DeliveredMessages = kvp.Value.DeliveredMessageCount, - DroppedMessages = kvp.Value.DroppedMessageCount - }); - - return new SubscriptionStats - { - TotalClients = _clientSubscriptions.Count, - TotalTags = _tagSubscriptions.Count, - TagClientCounts = tagClientCounts, - ClientStats = clientStats - }; - } - finally - { - _lock.ExitReadLock(); - } - } - - /// - /// Handles SCADA client connection state changes and notifies clients of disconnection. - /// - /// The event sender. - /// The connection state change event arguments. - private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e) - { - Logger.Information("Connection state changed from {Previous} to {Current}", - e.PreviousState, e.CurrentState); - - // If we're disconnected, notify all subscribed clients with bad quality - if (e.CurrentState != ConnectionState.Connected) - { - Task.Run(async () => - { - try - { - await NotifyAllClientsOfDisconnection(); - } - catch (Exception ex) - { - Logger.Error(ex, "Error notifying clients of disconnection"); - } - }); - } - } - - /// - /// Notifies all clients of a SCADA disconnection by sending bad quality updates. - /// - private async Task NotifyAllClientsOfDisconnection() - { - Logger.Information("Notifying all clients of disconnection"); - - var badQualityVtq = new Vtq(null, DateTime.UtcNow, Quality.Bad); - - // Get all unique addresses being subscribed to - var allAddresses = _tagSubscriptions.Keys.ToList(); - - // Send bad quality update for each address to all subscribed clients - foreach (string? address in allAddresses) - { - if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription)) - { - var clientIds = tagSubscription.ClientIds.ToList(); - - foreach (string? clientId in clientIds) - { - if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription)) - { - try - { - await clientSubscription.Channel.Writer.WriteAsync((address, badQualityVtq)); - Logger.Debug("Sent bad quality notification for {Address} to client {ClientId}", - address, clientId); - } - catch (Exception ex) - { - Logger.Warning(ex, "Failed to send bad quality notification to client {ClientId}", - clientId); - } - } - } - } - } - } - - /// - /// Represents a client's subscription, including channel, addresses, and statistics. - /// - private class ClientSubscription - { - /// - /// Gets or sets the client identifier. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// Gets or sets the channel for delivering tag updates. - /// - public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!; - - /// - /// Gets or sets the set of addresses the client is subscribed to. - /// - public HashSet Addresses { get; set; } = new(); - - /// - /// Gets or sets the cancellation token source for the client. - /// - public CancellationTokenSource CancellationTokenSource { get; set; } = null!; - - /// - /// Gets or sets the count of delivered messages. - /// - public long DeliveredMessageCount { get; set; } - - /// - /// Gets or sets the count of dropped messages. - /// - public long DroppedMessageCount { get; set; } - } - - /// - /// Represents a tag subscription, including address, client IDs, and SCADA subscription handle. - /// - private class TagSubscription - { - /// - /// Gets or sets the tag address. - /// - public string Address { get; set; } = string.Empty; - - /// - /// Gets or sets the set of client IDs subscribed to this tag. - /// - public HashSet ClientIds { get; set; } = new(); - - /// - /// Gets or sets the SCADA subscription handle. - /// - public IAsyncDisposable ScadaSubscription { get; set; } = null!; - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj deleted file mode 100644 index 59ddbcf..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +++ /dev/null @@ -1,65 +0,0 @@ - - - - net48 - Exe - 9.0 - enable - false - ZB.MOM.WW.LmxProxy.Host - ZB.MOM.WW.LmxProxy.Host - - x86 - x86 - true - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MXAccess.dll - true - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json deleted file mode 100644 index 5a3c19f..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.Production.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning", - "Grpc": "Information" - } - }, - "WriteTo": [ - { - "Name": "Console", - "Args": { - "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.json", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact" - } - } - ], - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId", - "WithProcessId", - "WithEnvironmentName" - ], - "Properties": { - "Application": "LmxProxy", - "Environment": "Production" - } - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json deleted file mode 100644 index a85b505..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "GrpcPort": 50051, - "ApiKeyConfigFile": "apikeys.json", - "Subscription": { - "ChannelCapacity": 1000, - "ChannelFullMode": "DropOldest" - }, - "ServiceRecovery": { - "FirstFailureDelayMinutes": 1, - "SecondFailureDelayMinutes": 5, - "SubsequentFailureDelayMinutes": 10, - "ResetPeriodDays": 1 - }, - "Connection": { - "MonitorIntervalSeconds": 5, - "ConnectionTimeoutSeconds": 30, - "AutoReconnect": true, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10 - }, - "PerformanceMetrics": { - "ReportingIntervalSeconds": 60, - "Enabled": true, - "MaxSamplesPerMetric": 1000 - }, - "HealthCheck": { - "Enabled": true, - "TestTagAddress": "TestChannel.TestDevice.TestTag", - "MaxStaleDataMinutes": 5 - }, - "RetryPolicies": { - "ReadRetryCount": 3, - "WriteRetryCount": 3, - "ConnectionRetryCount": 5, - "CircuitBreakerThreshold": 5, - "CircuitBreakerDurationSeconds": 30 - }, - "Tls": { - "Enabled": true, - "ServerCertificatePath": "certs/server.crt", - "ServerKeyPath": "certs/server.key", - "ClientCaCertificatePath": "certs/ca.crt", - "RequireClientCertificate": false, - "CheckCertificateRevocation": false - }, - "WebServer": { - "Enabled": true, - "Port": 8080 - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning", - "Grpc": "Information" - } - }, - "WriteTo": [ - { - "Name": "Console", - "Args": { - "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", - "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}" - } - } - ], - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ] - } -} diff --git a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json b/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json deleted file mode 100644 index 2d65477..0000000 --- a/deprecated/lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/appsettings.tls.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "GrpcPort": 50051, - "ApiKeyConfigFile": "apikeys.json", - "Connection": { - "MonitorIntervalSeconds": 5, - "ConnectionTimeoutSeconds": 30, - "AutoReconnect": true, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10 - }, - "Subscription": { - "ChannelCapacity": 10000, - "ChannelFullMode": "DropOldest" - }, - "ServiceRecovery": { - "FirstFailureDelayMinutes": 1, - "SecondFailureDelayMinutes": 5, - "SubsequentFailureDelayMinutes": 10, - "ResetPeriodDays": 1 - }, - "Tls": { - "Enabled": true, - "ServerCertificatePath": "certs/server.crt", - "ServerKeyPath": "certs/server.key", - "ClientCaCertificatePath": "certs/ca.crt", - "RequireClientCertificate": false, - "CheckCertificateRevocation": false - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning" - } - }, - "WriteTo": [ - { - "Name": "Console" - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.log", - "rollingInterval": "Day", - "retainedFileCountLimit": 7 - } - } - ] - } -} \ No newline at end of file diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientConfiguration.cs deleted file mode 100644 index c1c42de..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientConfiguration.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Configuration options for the LmxProxy client, typically set via the builder. -/// -internal class ClientConfiguration -{ - /// Maximum number of retry attempts for transient failures. - public int MaxRetryAttempts { get; set; } - - /// Base delay between retries (exponential backoff applied). - public TimeSpan RetryDelay { get; set; } - - /// Whether client-side metrics collection is enabled. - public bool EnableMetrics { get; set; } - - /// Optional header name for correlation ID propagation. - public string? CorrelationIdHeader { get; set; } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs deleted file mode 100644 index e7fe617..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// TLS configuration for the LmxProxy gRPC client. -/// -public class ClientTlsConfiguration -{ - /// Whether to use TLS for the gRPC connection. - public bool UseTls { get; set; } = false; - - /// Path to the client certificate PEM file for mTLS. - public string? ClientCertificatePath { get; set; } - - /// Path to the client private key PEM file for mTLS. - public string? ClientKeyPath { get; set; } - - /// Path to the server CA certificate PEM file for custom trust. - public string? ServerCaCertificatePath { get; set; } - - /// Override the server name used for TLS verification. - public string? ServerNameOverride { get; set; } - - /// Whether to validate the server certificate. - public bool ValidateServerCertificate { get; set; } = true; - - /// Whether to allow self-signed certificates. - public bool AllowSelfSignedCertificates { get; set; } = false; - - /// Whether to ignore all certificate errors (dangerous). - public bool IgnoreAllCertificateErrors { get; set; } = false; -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs deleted file mode 100644 index 2300961..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Represents the state of a connection to the LmxProxy service. -public enum ConnectionState -{ - Disconnected, - Connecting, - Connected, - Disconnecting, - Error, - Reconnecting -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs deleted file mode 100644 index 2272224..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// -/// OPC-style quality codes for SCADA data values. -/// Byte value matches OPC DA quality low byte for direct round-trip. -/// -public enum Quality : byte -{ - // ─────────────── Bad family (0-31) ─────────────── - Bad = 0, - Bad_ConfigError = 4, - Bad_NotConnected = 8, - Bad_DeviceFailure = 12, - Bad_SensorFailure = 16, - Bad_LastKnownValue = 20, - Bad_CommFailure = 24, - Bad_OutOfService = 28, - Bad_WaitingForInitialData = 32, - - // ──────────── Uncertain family (64-95) ─────────── - Uncertain = 64, - Uncertain_LowLimited = 65, - Uncertain_HighLimited = 66, - Uncertain_Constant = 67, - Uncertain_LastUsable = 68, - Uncertain_LastUsable_LL = 69, - Uncertain_LastUsable_HL = 70, - Uncertain_LastUsable_Cnst = 71, - Uncertain_SensorNotAcc = 80, - Uncertain_SensorNotAcc_LL = 81, - Uncertain_SensorNotAcc_HL = 82, - Uncertain_SensorNotAcc_C = 83, - Uncertain_EuExceeded = 84, - Uncertain_EuExceeded_LL = 85, - Uncertain_EuExceeded_HL = 86, - Uncertain_EuExceeded_C = 87, - Uncertain_SubNormal = 88, - Uncertain_SubNormal_LL = 89, - Uncertain_SubNormal_HL = 90, - Uncertain_SubNormal_C = 91, - - // ─────────────── Good family (192-219) ──────────── - Good = 192, - Good_LowLimited = 193, - Good_HighLimited = 194, - Good_Constant = 195, - Good_LocalOverride = 216, - Good_LocalOverride_LL = 217, - Good_LocalOverride_HL = 218, - Good_LocalOverride_C = 219 -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs deleted file mode 100644 index c2bd5c0..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Extension methods for . -public static class QualityExtensions -{ - /// Returns true if quality is in the Good family (byte >= 192). - public static bool IsGood(this Quality q) => (byte)q >= 192; - - /// Returns true if quality is in the Uncertain family (byte 64-127). - public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128; - - /// Returns true if quality is in the Bad family (byte < 64). - public static bool IsBad(this Quality q) => (byte)q < 64; - - /// - /// Converts an OPC UA 32-bit status code to the simplified enum. - /// Uses the top two bits to determine the quality family. - /// - public static Quality FromStatusCode(uint statusCode) - { - uint category = statusCode & 0xC0000000; - return category switch - { - 0x00000000 => Quality.Good, - 0x40000000 => Quality.Uncertain, - _ => Quality.Bad - }; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs deleted file mode 100644 index 6adc419..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs +++ /dev/null @@ -1,499 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.ServiceModel; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -// ──────────────────────────────────────────────────────────────── -// Service contract -// ──────────────────────────────────────────────────────────────── - -[ServiceContract(Name = "scada.ScadaService")] -public interface IScadaService -{ - ValueTask ConnectAsync(ConnectRequest request); - ValueTask DisconnectAsync(DisconnectRequest request); - ValueTask GetConnectionStateAsync(GetConnectionStateRequest request); - ValueTask ReadAsync(ReadRequest request); - ValueTask ReadBatchAsync(ReadBatchRequest request); - ValueTask WriteAsync(WriteRequest request); - ValueTask WriteBatchAsync(WriteBatchRequest request); - ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request); - IAsyncEnumerable SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default); - ValueTask CheckApiKeyAsync(CheckApiKeyRequest request); -} - -// ──────────────────────────────────────────────────────────────── -// Typed Value System (v2) -// ──────────────────────────────────────────────────────────────── - -/// -/// Carries a value in its native type via a protobuf oneof. -/// Exactly one property will be non-default. All-default = null value. -/// protobuf-net uses the first non-default field in field-number order for oneof. -/// -[DataContract] -public class TypedValue -{ - // Tracks which oneof field was set (by property setter during deserialization or manual assignment). - private TypedValueCase _setCase = TypedValueCase.None; - - private bool _boolValue; - private int _int32Value; - private long _int64Value; - private float _floatValue; - private double _doubleValue; - private string? _stringValue; - private byte[]? _bytesValue; - private long _datetimeValue; - private ArrayValue? _arrayValue; - - [DataMember(Order = 1)] - public bool BoolValue { get => _boolValue; set { _boolValue = value; _setCase = TypedValueCase.BoolValue; } } - - [DataMember(Order = 2)] - public int Int32Value { get => _int32Value; set { _int32Value = value; _setCase = TypedValueCase.Int32Value; } } - - [DataMember(Order = 3)] - public long Int64Value { get => _int64Value; set { _int64Value = value; _setCase = TypedValueCase.Int64Value; } } - - [DataMember(Order = 4)] - public float FloatValue { get => _floatValue; set { _floatValue = value; _setCase = TypedValueCase.FloatValue; } } - - [DataMember(Order = 5)] - public double DoubleValue { get => _doubleValue; set { _doubleValue = value; _setCase = TypedValueCase.DoubleValue; } } - - [DataMember(Order = 6)] - public string? StringValue { get => _stringValue; set { _stringValue = value; _setCase = TypedValueCase.StringValue; } } - - [DataMember(Order = 7)] - public byte[]? BytesValue { get => _bytesValue; set { _bytesValue = value; _setCase = TypedValueCase.BytesValue; } } - - [DataMember(Order = 8)] - public long DatetimeValue { get => _datetimeValue; set { _datetimeValue = value; _setCase = TypedValueCase.DatetimeValue; } } - - [DataMember(Order = 9)] - public ArrayValue? ArrayValue { get => _arrayValue; set { _arrayValue = value; _setCase = TypedValueCase.ArrayValue; } } - - /// - /// Indicates which oneof case is set. Tracked via property setters so default values - /// (false, 0, 0.0) are correctly distinguished from "not set". - /// - public TypedValueCase GetValueCase() => _setCase; -} - -/// Identifies which field in TypedValue is set. -public enum TypedValueCase -{ - None = 0, - BoolValue = 1, - Int32Value = 2, - Int64Value = 3, - FloatValue = 4, - DoubleValue = 5, - StringValue = 6, - BytesValue = 7, - DatetimeValue = 8, - ArrayValue = 9 -} - -/// Container for typed arrays. Exactly one field will be set. -[DataContract] -public class ArrayValue -{ - [DataMember(Order = 1)] - public BoolArray? BoolValues { get; set; } - - [DataMember(Order = 2)] - public Int32Array? Int32Values { get; set; } - - [DataMember(Order = 3)] - public Int64Array? Int64Values { get; set; } - - [DataMember(Order = 4)] - public FloatArray? FloatValues { get; set; } - - [DataMember(Order = 5)] - public DoubleArray? DoubleValues { get; set; } - - [DataMember(Order = 6)] - public StringArray? StringValues { get; set; } - - [DataMember(Order = 7)] - public DatetimeArray? DatetimeValues { get; set; } -} - -[DataContract] -public class BoolArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class Int32Array -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class Int64Array -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class FloatArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class DoubleArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class StringArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -[DataContract] -public class DatetimeArray -{ - [DataMember(Order = 1)] - public List Values { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// Quality Code (v2) -// ──────────────────────────────────────────────────────────────── - -/// -/// OPC UA-style quality code with numeric status code and symbolic name. -/// -[DataContract] -public class QualityCode -{ - [DataMember(Order = 1)] - public uint StatusCode { get; set; } - - [DataMember(Order = 2)] - public string SymbolicName { get; set; } = string.Empty; - - /// Returns true if quality category is Good (high bits 0x00). - public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000; - - /// Returns true if quality category is Uncertain (high bits 0x40). - public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000; - - /// Returns true if quality category is Bad (high bits 0x80). - public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000; -} - -// ──────────────────────────────────────────────────────────────── -// VTQ message (v2) -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class VtqMessage -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public TypedValue? Value { get; set; } - - [DataMember(Order = 3)] - public long TimestampUtcTicks { get; set; } - - [DataMember(Order = 4)] - public QualityCode? Quality { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Connect -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class ConnectRequest -{ - [DataMember(Order = 1)] - public string ClientId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string ApiKey { get; set; } = string.Empty; -} - -[DataContract] -public class ConnectResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public string SessionId { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// Disconnect -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class DisconnectRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -[DataContract] -public class DisconnectResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -// ──────────────────────────────────────────────────────────────── -// GetConnectionState -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class GetConnectionStateRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; -} - -[DataContract] -public class GetConnectionStateResponse -{ - [DataMember(Order = 1)] - public bool IsConnected { get; set; } - - [DataMember(Order = 2)] - public string ClientId { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public long ConnectedSinceUtcTicks { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Read -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class ReadRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; -} - -[DataContract] -public class ReadResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public VtqMessage? Vtq { get; set; } -} - -[DataContract] -public class ReadBatchRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Tags { get; set; } = []; -} - -[DataContract] -public class ReadBatchResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List Vtqs { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// Write -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class WriteRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public TypedValue? Value { get; set; } -} - -[DataContract] -public class WriteResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} - -[DataContract] -public class WriteItem -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public TypedValue? Value { get; set; } -} - -[DataContract] -public class WriteResult -{ - [DataMember(Order = 1)] - public string Tag { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public bool Success { get; set; } - - [DataMember(Order = 3)] - public string Message { get; set; } = string.Empty; -} - -[DataContract] -public class WriteBatchRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Items { get; set; } = []; -} - -[DataContract] -public class WriteBatchResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List Results { get; set; } = []; -} - -// ──────────────────────────────────────────────────────────────── -// WriteBatchAndWait -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class WriteBatchAndWaitRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Items { get; set; } = []; - - [DataMember(Order = 3)] - public string FlagTag { get; set; } = string.Empty; - - [DataMember(Order = 4)] - public TypedValue? FlagValue { get; set; } - - [DataMember(Order = 5)] - public int TimeoutMs { get; set; } - - [DataMember(Order = 6)] - public int PollIntervalMs { get; set; } -} - -[DataContract] -public class WriteBatchAndWaitResponse -{ - [DataMember(Order = 1)] - public bool Success { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; - - [DataMember(Order = 3)] - public List WriteResults { get; set; } = []; - - [DataMember(Order = 4)] - public bool FlagReached { get; set; } - - [DataMember(Order = 5)] - public int ElapsedMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// Subscribe -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class SubscribeRequest -{ - [DataMember(Order = 1)] - public string SessionId { get; set; } = string.Empty; - - [DataMember(Order = 2)] - public List Tags { get; set; } = []; - - [DataMember(Order = 3)] - public int SamplingMs { get; set; } -} - -// ──────────────────────────────────────────────────────────────── -// CheckApiKey -// ──────────────────────────────────────────────────────────────── - -[DataContract] -public class CheckApiKeyRequest -{ - [DataMember(Order = 1)] - public string ApiKey { get; set; } = string.Empty; -} - -[DataContract] -public class CheckApiKeyResponse -{ - [DataMember(Order = 1)] - public bool IsValid { get; set; } - - [DataMember(Order = 2)] - public string Message { get; set; } = string.Empty; -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs deleted file mode 100644 index a8da4a9..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.Domain; - -/// Value, Timestamp, and Quality for SCADA data. -public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality) -{ - public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - - public override string ToString() => - $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs deleted file mode 100644 index f07a724..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Interface for LmxProxy client operations. -/// -public interface ILmxProxyClient : IDisposable, IAsyncDisposable -{ - /// Gets or sets the default timeout for operations (range: 1s to 10min). - TimeSpan DefaultTimeout { get; set; } - - /// Connects to the LmxProxy service and establishes a session. - Task ConnectAsync(CancellationToken cancellationToken = default); - - /// Disconnects from the LmxProxy service. - Task DisconnectAsync(); - - /// Returns true if the client has an active session. - Task IsConnectedAsync(); - - /// Reads a single tag value. - Task ReadAsync(string address, CancellationToken cancellationToken = default); - - /// Reads multiple tag values in a single batch. - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default); - - /// Writes a single tag value (native TypedValue -- no string heuristics). - Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default); - - /// Writes multiple tag values in a single batch. - Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default); - - /// - /// Writes a batch of values, then polls a flag tag until it matches or timeout expires. - /// Returns (writeResults, flagReached, elapsedMs). - /// - Task WriteBatchAndWaitAsync( - IDictionary values, - string flagTag, - TypedValue flagValue, - int timeoutMs = 5000, - int pollIntervalMs = 100, - CancellationToken cancellationToken = default); - - /// Subscribes to tag updates with value and error callbacks. - Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default); - - /// Validates an API key and returns info. - Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default); - - /// Returns a snapshot of client-side metrics. - Dictionary GetMetrics(); -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs deleted file mode 100644 index 2b23252..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Factory for creating instances. -/// -public interface ILmxProxyClientFactory -{ - /// Creates a client from the default "LmxProxy" configuration section. - LmxProxyClient CreateClient(); - - /// Creates a client from a named configuration section. - LmxProxyClient CreateClient(string configName); - - /// Creates a client using a builder configuration action. - LmxProxyClient CreateClient(Action builderAction); -} - -/// -/// Default implementation of that reads from IConfiguration. -/// -public class LmxProxyClientFactory : ILmxProxyClientFactory -{ - private readonly IConfiguration _configuration; - - /// Creates a new factory with the specified configuration. - public LmxProxyClientFactory(IConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - /// - public LmxProxyClient CreateClient() => CreateClient("LmxProxy"); - - /// - public LmxProxyClient CreateClient(string configName) - { - IConfigurationSection section = _configuration.GetSection(configName); - var options = new LmxProxyClientOptions(); - section.Bind(options); - return BuildFromOptions(options); - } - - /// - public LmxProxyClient CreateClient(Action builderAction) - { - var builder = new LmxProxyClientBuilder(); - builderAction(builder); - return builder.Build(); - } - - private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options) - { - var builder = new LmxProxyClientBuilder() - .WithHost(options.Host) - .WithPort(options.Port) - .WithTimeout(options.Timeout) - .WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay); - - if (!string.IsNullOrEmpty(options.ApiKey)) - builder.WithApiKey(options.ApiKey); - - if (options.EnableMetrics) - builder.WithMetrics(); - - if (!string.IsNullOrEmpty(options.CorrelationIdHeader)) - builder.WithCorrelationIdHeader(options.CorrelationIdHeader); - - if (options.UseSsl) - { - builder.WithTlsConfiguration(new ClientTlsConfiguration - { - UseTls = true, - ServerCaCertificatePath = options.CertificatePath - }); - } - - return builder.Build(); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs deleted file mode 100644 index 841b79a..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - /// Result of an API key validation check. - /// - public class ApiKeyInfo - { - /// Whether the API key is valid. - public bool IsValid { get; init; } - - /// Role associated with the API key. - public string? Role { get; init; } - - /// Description or message from the server. - public string? Description { get; init; } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs deleted file mode 100644 index c3ad9b7..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Concurrent; - -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - /// Tracks per-operation counts, errors, and latency with rolling buffer and percentile support. - /// - internal class ClientMetrics - { - private readonly ConcurrentDictionary _operationCounts = new(); - private readonly ConcurrentDictionary _errorCounts = new(); - private readonly ConcurrentDictionary> _latencies = new(); - private readonly Lock _latencyLock = new(); - - public void IncrementOperationCount(string operation) - { - _operationCounts.AddOrUpdate(operation, 1, (_, count) => count + 1); - } - - public void IncrementErrorCount(string operation) - { - _errorCounts.AddOrUpdate(operation, 1, (_, count) => count + 1); - } - - public void RecordLatency(string operation, long milliseconds) - { - lock (_latencyLock) - { - if (!_latencies.TryGetValue(operation, out var list)) - { - list = []; - _latencies[operation] = list; - } - list.Add(milliseconds); - if (list.Count > 1000) - { - list.RemoveAt(0); - } - } - } - - public Dictionary GetSnapshot() - { - var snapshot = new Dictionary(); - - foreach (var kvp in _operationCounts) - { - snapshot[$"{kvp.Key}_count"] = kvp.Value; - } - - foreach (var kvp in _errorCounts) - { - snapshot[$"{kvp.Key}_errors"] = kvp.Value; - } - - lock (_latencyLock) - { - foreach (var kvp in _latencies) - { - var values = kvp.Value; - if (values.Count == 0) continue; - - double avg = values.Average(); - snapshot[$"{kvp.Key}_avg_latency_ms"] = Math.Round(avg, 2); - snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(values, 95); - snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(values, 99); - } - } - - return snapshot; - } - - private static long GetPercentile(List values, int percentile) - { - var sorted = values.OrderBy(v => v).ToList(); - int index = Math.Max(0, (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1); - return sorted[index]; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs deleted file mode 100644 index deaf7f8..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - private class CodeFirstSubscription : ISubscription - { - private readonly IScadaService _client; - private readonly string _sessionId; - private readonly List _tags; - private readonly Action _onUpdate; - private readonly Action? _onStreamError; - private readonly ILogger _logger; - private readonly Action? _onDispose; - private readonly CancellationTokenSource _cts = new(); - private Task? _processingTask; - private bool _disposed; - private bool _streamErrorFired; - - public CodeFirstSubscription( - IScadaService client, - string sessionId, - List tags, - Action onUpdate, - Action? onStreamError, - ILogger logger, - Action? onDispose) - { - _client = client; - _sessionId = sessionId; - _tags = tags; - _onUpdate = onUpdate; - _onStreamError = onStreamError; - _logger = logger; - _onDispose = onDispose; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _processingTask = ProcessUpdatesAsync(cancellationToken); - return Task.CompletedTask; - } - - private async Task ProcessUpdatesAsync(CancellationToken cancellationToken) - { - try - { - var request = new SubscribeRequest - { - SessionId = _sessionId, - Tags = _tags, - SamplingMs = 1000 - }; - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); - - await foreach (VtqMessage vtqMsg in _client.SubscribeAsync(request, linkedCts.Token)) - { - try - { - Vtq vtq = ConvertVtqMessage(vtqMsg); - _onUpdate(vtqMsg.Tag, vtq); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag); - } - } - } - catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested) - { - _logger.LogDebug("Subscription cancelled"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in subscription processing"); - FireStreamError(ex); - } - finally - { - if (!_disposed) - { - _disposed = true; - _onDispose?.Invoke(this); - } - } - } - - private void FireStreamError(Exception ex) - { - if (_streamErrorFired) return; - _streamErrorFired = true; - try { _onStreamError?.Invoke(ex); } - catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); } - } - - public async Task DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - await _cts.CancelAsync(); - - if (_processingTask is not null) - { - try - { - await _processingTask.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch { /* swallow timeout or cancellation */ } - } - - _cts.Dispose(); - } - - public void Dispose() - { - if (_disposed) return; - try - { - DisposeAsync().Wait(TimeSpan.FromSeconds(5)); - } - catch { /* swallow */ } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs deleted file mode 100644 index 1b2bc9d..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs +++ /dev/null @@ -1,219 +0,0 @@ -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; -using ProtoBuf.Grpc.Client; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Security; - -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - public async Task ConnectAsync(CancellationToken cancellationToken = default) - { - await _connectionLock.WaitAsync(cancellationToken); - try - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (IsConnected) - return; - - var endpoint = BuildEndpointUri(); - _logger.LogInformation("Connecting to LmxProxy at {Endpoint}", endpoint); - - GrpcChannel channel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger, _apiKey); - IScadaService client; - try - { - client = channel.CreateGrpcService(); - } - catch - { - channel.Dispose(); - throw; - } - - ConnectResponse response; - try - { - var request = new ConnectRequest - { - ClientId = $"ScadaBridge-{Guid.NewGuid():N}", - ApiKey = _apiKey ?? string.Empty - }; - response = await client.ConnectAsync(request); - } - catch - { - channel.Dispose(); - throw; - } - - if (!response.Success) - { - channel.Dispose(); - throw new InvalidOperationException($"Connect failed: {response.Message}"); - } - - _channel = channel; - _client = client; - _sessionId = response.SessionId; - _isConnected = true; - - StartKeepAlive(); - - _logger.LogInformation("Connected to LmxProxy, session={SessionId}", _sessionId); - } - catch (Exception ex) - { - _channel = null; - _client = null; - _sessionId = string.Empty; - _isConnected = false; - _logger.LogError(ex, "Failed to connect to LmxProxy"); - throw; - } - finally - { - _connectionLock.Release(); - } - } - - /// - public async Task DisconnectAsync() - { - await _connectionLock.WaitAsync(); - try - { - StopKeepAlive(); - - if (_client is not null && !string.IsNullOrEmpty(_sessionId)) - { - try - { - await _client.DisconnectAsync(new DisconnectRequest { SessionId = _sessionId }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error sending disconnect request"); - } - } - - _client = null; - _sessionId = string.Empty; - _isConnected = false; - _channel?.Dispose(); - _channel = null; - } - finally - { - _connectionLock.Release(); - } - } - - /// - public async Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default) - { - EnsureConnected(); - - var subscription = new CodeFirstSubscription( - _client!, - _sessionId, - addresses.ToList(), - onUpdate, - onStreamError, - _logger, - sub => - { - lock (_subscriptionLock) - { - _activeSubscriptions.Remove(sub); - } - }); - - lock (_subscriptionLock) - { - _activeSubscriptions.Add(subscription); - } - - await subscription.StartAsync(cancellationToken); - return subscription; - } - - private void StartKeepAlive() - { - _keepAliveTimer = new Timer( - async _ => await KeepAliveCallback(), - null, - _keepAliveInterval, - _keepAliveInterval); - } - - private async Task KeepAliveCallback() - { - try - { - if (_client is null || string.IsNullOrEmpty(_sessionId)) - return; - - await _client.GetConnectionStateAsync(new GetConnectionStateRequest { SessionId = _sessionId }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Keep-alive failed, marking disconnected"); - StopKeepAlive(); - await MarkDisconnectedAsync(ex); - } - } - - private void StopKeepAlive() - { - _keepAliveTimer?.Dispose(); - _keepAliveTimer = null; - } - - internal async Task MarkDisconnectedAsync(Exception ex) - { - if (_disposed) return; - - await _connectionLock.WaitAsync(); - try - { - _isConnected = false; - _client = null; - _sessionId = string.Empty; - _channel?.Dispose(); - _channel = null; - } - finally - { - _connectionLock.Release(); - } - - List subscriptions; - lock (_subscriptionLock) - { - subscriptions = [.. _activeSubscriptions]; - _activeSubscriptions.Clear(); - } - - foreach (var sub in subscriptions) - { - try { sub.Dispose(); } - catch { /* swallow */ } - } - - _logger.LogWarning(ex, "Client marked as disconnected"); - } - - private Uri BuildEndpointUri() - { - string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; - return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs deleted file mode 100644 index ae371b1..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -public partial class LmxProxyClient -{ - /// - /// Represents an active tag subscription. Dispose to unsubscribe. - /// - public interface ISubscription : IDisposable - { - /// Asynchronous disposal with cancellation support. - Task DisposeAsync(); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs deleted file mode 100644 index e970b95..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System.Diagnostics; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Polly; -using Polly.Retry; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// gRPC client for the LmxProxy SCADA proxy service. Uses v2 protocol with native TypedValue. -/// -public partial class LmxProxyClient : ILmxProxyClient -{ - private readonly ILogger _logger; - private readonly string _host; - private readonly int _port; - private readonly string? _apiKey; - private readonly ClientTlsConfiguration? _tlsConfiguration; - private readonly ClientMetrics _metrics = new(); - private readonly SemaphoreSlim _connectionLock = new(1, 1); - private readonly List _activeSubscriptions = []; - private readonly Lock _subscriptionLock = new(); - - private GrpcChannel? _channel; - private IScadaService? _client; - private string _sessionId = string.Empty; - private bool _disposed; - private bool _isConnected; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private ClientConfiguration? _configuration; - private ResiliencePipeline? _resiliencePipeline; - private Timer? _keepAliveTimer; - private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30); - - /// Returns true if the client has an active session and is not disposed. - public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId); - - /// - public TimeSpan DefaultTimeout - { - get => _defaultTimeout; - set - { - if (value < TimeSpan.FromSeconds(1) || value > TimeSpan.FromMinutes(10)) - throw new ArgumentOutOfRangeException(nameof(value), "DefaultTimeout must be between 1 second and 10 minutes."); - _defaultTimeout = value; - } - } - - /// - /// Creates a new LmxProxyClient instance. - /// - public LmxProxyClient( - string host, int port, string? apiKey, - ClientTlsConfiguration? tlsConfiguration, - ILogger? logger = null) - { - _host = host ?? throw new ArgumentNullException(nameof(host)); - _port = port; - _apiKey = apiKey; - _tlsConfiguration = tlsConfiguration; - _logger = logger ?? NullLogger.Instance; - } - - /// - /// Sets builder configuration including retry policies. Called internally by the builder. - /// - internal void SetBuilderConfiguration(ClientConfiguration config) - { - _configuration = config; - if (config.MaxRetryAttempts > 0) - { - _resiliencePipeline = new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = config.MaxRetryAttempts, - Delay = config.RetryDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder() - .Handle(ex => - ex.StatusCode == StatusCode.Unavailable || - ex.StatusCode == StatusCode.DeadlineExceeded || - ex.StatusCode == StatusCode.ResourceExhausted || - ex.StatusCode == StatusCode.Aborted), - OnRetry = args => - { - _logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}", - args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message); - return ValueTask.CompletedTask; - } - }) - .Build(); - } - } - - /// - public async Task ReadAsync(string address, CancellationToken cancellationToken = default) - { - EnsureConnected(); - _metrics.IncrementOperationCount("Read"); - var sw = Stopwatch.StartNew(); - try - { - var request = new ReadRequest { SessionId = _sessionId, Tag = address }; - ReadResponse response = await ExecuteWithRetry( - () => _client!.ReadAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"Read failed: {response.Message}"); - return ConvertVtqMessage(response.Vtq); - } - catch - { - _metrics.IncrementErrorCount("Read"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("Read", sw.ElapsedMilliseconds); - } - } - - /// - public async Task> ReadBatchAsync( - IEnumerable addresses, CancellationToken cancellationToken = default) - { - EnsureConnected(); - _metrics.IncrementOperationCount("ReadBatch"); - var sw = Stopwatch.StartNew(); - try - { - var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() }; - ReadBatchResponse response = await ExecuteWithRetry( - () => _client!.ReadBatchAsync(request).AsTask(), cancellationToken); - var result = new Dictionary(); - foreach (var vtqMsg in response.Vtqs) - { - result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg); - } - return result; - } - catch - { - _metrics.IncrementErrorCount("ReadBatch"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds); - } - } - - /// - public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) - { - EnsureConnected(); - _metrics.IncrementOperationCount("Write"); - var sw = Stopwatch.StartNew(); - try - { - var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value }; - WriteResponse response = await ExecuteWithRetry( - () => _client!.WriteAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"Write failed: {response.Message}"); - } - catch - { - _metrics.IncrementErrorCount("Write"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("Write", sw.ElapsedMilliseconds); - } - } - - /// - public async Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) - { - EnsureConnected(); - _metrics.IncrementOperationCount("WriteBatch"); - var sw = Stopwatch.StartNew(); - try - { - var request = new WriteBatchRequest - { - SessionId = _sessionId, - Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList() - }; - WriteBatchResponse response = await ExecuteWithRetry( - () => _client!.WriteBatchAsync(request).AsTask(), cancellationToken); - if (!response.Success) - throw new InvalidOperationException($"WriteBatch failed: {response.Message}"); - } - catch - { - _metrics.IncrementErrorCount("WriteBatch"); - throw; - } - finally - { - sw.Stop(); - _metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds); - } - } - - /// - public async Task WriteBatchAndWaitAsync( - IDictionary values, string flagTag, TypedValue flagValue, - int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default) - { - EnsureConnected(); - var request = new WriteBatchAndWaitRequest - { - SessionId = _sessionId, - Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(), - FlagTag = flagTag, - FlagValue = flagValue, - TimeoutMs = timeoutMs, - PollIntervalMs = pollIntervalMs - }; - return await ExecuteWithRetry( - () => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken); - } - - /// - public async Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) - { - EnsureConnected(); - var request = new CheckApiKeyRequest { ApiKey = apiKey }; - CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request); - return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message }; - } - - /// - public Task IsConnectedAsync() => Task.FromResult(IsConnected); - - /// - public Dictionary GetMetrics() => _metrics.GetSnapshot(); - - internal static Vtq ConvertVtqMessage(VtqMessage? msg) - { - if (msg is null) - return new Vtq(null, DateTime.UtcNow, Quality.Bad); - - object? value = ExtractTypedValue(msg.Value); - DateTime timestamp = msg.TimestampUtcTicks > 0 - ? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc) - : DateTime.UtcNow; - Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u); - return new Vtq(value, timestamp, quality); - } - - internal static object? ExtractTypedValue(TypedValue? tv) - { - if (tv is null) return null; - - return tv.GetValueCase() switch - { - TypedValueCase.BoolValue => tv.BoolValue, - TypedValueCase.Int32Value => tv.Int32Value, - TypedValueCase.Int64Value => tv.Int64Value, - TypedValueCase.FloatValue => tv.FloatValue, - TypedValueCase.DoubleValue => tv.DoubleValue, - TypedValueCase.StringValue => tv.StringValue, - TypedValueCase.BytesValue => tv.BytesValue, - TypedValueCase.DatetimeValue => new DateTime(tv.DatetimeValue, DateTimeKind.Utc), - TypedValueCase.ArrayValue => ExtractArrayValue(tv.ArrayValue), - TypedValueCase.None => null, - _ => null - }; - } - - internal static object? ExtractArrayValue(ArrayValue? av) - { - if (av is null) return null; - if (av.BoolValues is not null) return av.BoolValues.Values.ToArray(); - if (av.Int32Values is not null) return av.Int32Values.Values.ToArray(); - if (av.Int64Values is not null) return av.Int64Values.Values.ToArray(); - if (av.FloatValues is not null) return av.FloatValues.Values.ToArray(); - if (av.DoubleValues is not null) return av.DoubleValues.Values.ToArray(); - if (av.StringValues is not null) return av.StringValues.Values.ToArray(); - if (av.DatetimeValues is not null) - { - return av.DatetimeValues.Values - .Select(ticks => new DateTime(ticks, DateTimeKind.Utc)) - .ToArray(); - } - return null; - } - - private async Task ExecuteWithRetry(Func> operation, CancellationToken ct) - { - if (_resiliencePipeline is not null) - { - return await _resiliencePipeline.ExecuteAsync( - async token => await operation(), ct); - } - return await operation(); - } - - private void EnsureConnected() - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (!IsConnected) - throw new InvalidOperationException("Client is not connected. Call ConnectAsync first."); - } - - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _keepAliveTimer?.Dispose(); - _channel?.Dispose(); - _connectionLock.Dispose(); - } - - /// - public async ValueTask DisposeAsync() - { - if (_disposed) return; - try { await DisconnectAsync(); } catch { /* swallow */ } - Dispose(); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs deleted file mode 100644 index 67ca7e4..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Fluent builder for creating configured instances. -/// -public class LmxProxyClientBuilder -{ - private string? _host; - private int _port = 50051; - private string? _apiKey; - private ILogger? _logger; - private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); - private int _maxRetryAttempts = 3; - private TimeSpan _retryDelay = TimeSpan.FromSeconds(1); - private bool _enableMetrics; - private string? _correlationIdHeader; - private ClientTlsConfiguration? _tlsConfiguration; - - /// Sets the host address of the LmxProxy server. Required. - public LmxProxyClientBuilder WithHost(string host) - { - if (string.IsNullOrWhiteSpace(host)) - throw new ArgumentException("Host must not be null or empty.", nameof(host)); - _host = host; - return this; - } - - /// Sets the port of the LmxProxy server. Default is 50051. - public LmxProxyClientBuilder WithPort(int port) - { - if (port < 1 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535."); - _port = port; - return this; - } - - /// Sets the API key for authentication. - public LmxProxyClientBuilder WithApiKey(string? apiKey) - { - _apiKey = apiKey; - return this; - } - - /// Sets the logger instance for the client. - public LmxProxyClientBuilder WithLogger(ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - return this; - } - - /// Sets the default timeout for operations. Must be between 1 second and 10 minutes. - public LmxProxyClientBuilder WithTimeout(TimeSpan timeout) - { - if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromMinutes(10)) - throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero and at most 10 minutes."); - _defaultTimeout = timeout; - return this; - } - - /// Enables TLS with an optional server CA certificate path. - public LmxProxyClientBuilder WithSslCredentials(string? certificatePath) - { - _tlsConfiguration ??= new ClientTlsConfiguration(); - _tlsConfiguration.UseTls = true; - _tlsConfiguration.ServerCaCertificatePath = certificatePath; - return this; - } - - /// Sets a full TLS configuration. - public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration config) - { - _tlsConfiguration = config ?? throw new ArgumentNullException(nameof(config)); - return this; - } - - /// Configures the retry policy. maxAttempts must be positive, retryDelay must be positive. - public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay) - { - if (maxAttempts <= 0) - throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max retry attempts must be greater than zero."); - if (retryDelay <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be greater than zero."); - _maxRetryAttempts = maxAttempts; - _retryDelay = retryDelay; - return this; - } - - /// Enables client-side metrics collection. - public LmxProxyClientBuilder WithMetrics() - { - _enableMetrics = true; - return this; - } - - /// Sets the correlation ID header name for request tracing. - public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName) - { - if (string.IsNullOrEmpty(headerName)) - throw new ArgumentException("Header name must not be null or empty.", nameof(headerName)); - _correlationIdHeader = headerName; - return this; - } - - /// - /// Builds and returns a configured instance. - /// - /// Thrown when host is not set. - /// Thrown when TLS certificate paths don't exist. - public LmxProxyClient Build() - { - if (string.IsNullOrWhiteSpace(_host)) - throw new InvalidOperationException("Host must be specified. Call WithHost() before Build()."); - - ValidateTlsConfiguration(); - - var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger) - { - DefaultTimeout = _defaultTimeout - }; - - client.SetBuilderConfiguration(new ClientConfiguration - { - MaxRetryAttempts = _maxRetryAttempts, - RetryDelay = _retryDelay, - EnableMetrics = _enableMetrics, - CorrelationIdHeader = _correlationIdHeader - }); - - return client; - } - - private void ValidateTlsConfiguration() - { - if (_tlsConfiguration?.UseTls != true) - return; - - if (!string.IsNullOrEmpty(_tlsConfiguration.ServerCaCertificatePath) && - !File.Exists(_tlsConfiguration.ServerCaCertificatePath)) - throw new FileNotFoundException( - $"Server CA certificate not found: {_tlsConfiguration.ServerCaCertificatePath}", - _tlsConfiguration.ServerCaCertificatePath); - - if (!string.IsNullOrEmpty(_tlsConfiguration.ClientCertificatePath) && - !File.Exists(_tlsConfiguration.ClientCertificatePath)) - throw new FileNotFoundException( - $"Client certificate not found: {_tlsConfiguration.ClientCertificatePath}", - _tlsConfiguration.ClientCertificatePath); - - if (!string.IsNullOrEmpty(_tlsConfiguration.ClientKeyPath) && - !File.Exists(_tlsConfiguration.ClientKeyPath)) - throw new FileNotFoundException( - $"Client key not found: {_tlsConfiguration.ClientKeyPath}", - _tlsConfiguration.ClientKeyPath); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientOptions.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientOptions.cs deleted file mode 100644 index 9933dd8..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientOptions.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Configuration options for creating an LmxProxy client from IConfiguration sections. -/// -public class LmxProxyClientOptions -{ - /// Host address of the LmxProxy server. - public string Host { get; set; } = "localhost"; - - /// Port of the LmxProxy server. - public int Port { get; set; } = 50051; - - /// API key for authentication. - public string? ApiKey { get; set; } - - /// Default timeout for operations. - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); - - /// Whether to use TLS for the connection. - public bool UseSsl { get; set; } - - /// Path to the server CA certificate for TLS. - public string? CertificatePath { get; set; } - - /// Whether to enable client-side metrics collection. - public bool EnableMetrics { get; set; } - - /// Optional header name for correlation ID propagation. - public string? CorrelationIdHeader { get; set; } - - /// Retry policy options. - public RetryOptions Retry { get; set; } = new(); -} - -/// -/// Retry policy configuration options. -/// -public class RetryOptions -{ - /// Maximum number of retry attempts. - public int MaxAttempts { get; set; } = 3; - - /// Base delay between retries. - public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1); -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs deleted file mode 100644 index d24ce4e..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using Grpc.Net.Client; -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.LmxProxy.Client.Security; - -/// -/// Factory for creating configured gRPC channels with TLS support. -/// -internal static class GrpcChannelFactory -{ -#pragma warning disable CA1810 // Initialize reference type static fields inline - static GrpcChannelFactory() -#pragma warning restore CA1810 - { - // Enable HTTP/2 over plaintext for non-TLS scenarios - AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); - } - - /// - /// Creates a with the specified address, TLS configuration, and optional API key header. - /// - public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger, string? apiKey = null) - { - var handler = new SocketsHttpHandler - { - EnableMultipleHttp2Connections = true - }; - - if (tlsConfiguration?.UseTls == true) - { - ConfigureTls(handler, tlsConfiguration, logger); - } - - HttpMessageHandler finalHandler = handler; - - // Add API key header to all outgoing requests if provided - if (!string.IsNullOrEmpty(apiKey)) - { - finalHandler = new ApiKeyDelegatingHandler(apiKey, handler); - } - - var channelOptions = new GrpcChannelOptions - { - HttpHandler = finalHandler - }; - - logger.LogDebug("Creating gRPC channel to {Address}, TLS={UseTls}", address, tlsConfiguration?.UseTls ?? false); - return GrpcChannel.ForAddress(address, channelOptions); - } - - /// - /// DelegatingHandler that adds the x-api-key header to all outgoing requests. - /// - private sealed class ApiKeyDelegatingHandler : DelegatingHandler - { - private readonly string _apiKey; - - public ApiKeyDelegatingHandler(string apiKey, HttpMessageHandler innerHandler) - : base(innerHandler) - { - _apiKey = apiKey; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - request.Headers.TryAddWithoutValidation("x-api-key", _apiKey); - return base.SendAsync(request, cancellationToken); - } - } - - private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tls, ILogger logger) - { - handler.SslOptions = new SslClientAuthenticationOptions - { - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 - }; - - if (!string.IsNullOrEmpty(tls.ServerNameOverride)) - { - handler.SslOptions.TargetHost = tls.ServerNameOverride; - } - - // Load client certificate for mTLS - if (!string.IsNullOrEmpty(tls.ClientCertificatePath) && !string.IsNullOrEmpty(tls.ClientKeyPath)) - { - var clientCert = X509Certificate2.CreateFromPemFile(tls.ClientCertificatePath, tls.ClientKeyPath); - handler.SslOptions.ClientCertificates = [clientCert]; - logger.LogDebug("Loaded client certificate for mTLS from {Path}", tls.ClientCertificatePath); - } - - // Certificate validation callback - handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => - { - if (tls.IgnoreAllCertificateErrors) - { - logger.LogWarning("Ignoring all certificate errors (IgnoreAllCertificateErrors=true)"); - return true; - } - - if (!tls.ValidateServerCertificate) - { - return true; - } - - if (sslPolicyErrors == SslPolicyErrors.None) - return true; - - // Custom CA trust store - if (!string.IsNullOrEmpty(tls.ServerCaCertificatePath) && certificate is not null) - { - using var customChain = new X509Chain(); - customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - customChain.ChainPolicy.CustomTrustStore.Add(X509CertificateLoader.LoadCertificateFromFile(tls.ServerCaCertificatePath)); - if (customChain.Build(new X509Certificate2(certificate))) - return true; - } - - if (tls.AllowSelfSignedCertificates && sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors) - { - logger.LogWarning("Allowing self-signed certificate"); - return true; - } - - logger.LogError("Certificate validation failed: {Errors}", sslPolicyErrors); - return false; - }; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs deleted file mode 100644 index ac75089..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Extension methods for registering LmxProxy client services in the DI container. -/// -public static class ServiceCollectionExtensions -{ - /// Registers a singleton ILmxProxyClient from the "LmxProxy" config section. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, IConfiguration configuration) - { - return services.AddLmxProxyClient(configuration, "LmxProxy"); - } - - /// Registers a singleton ILmxProxyClient from a named config section. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, IConfiguration configuration, string sectionName) - { - services.AddSingleton( - sp => new LmxProxyClientFactory(configuration)); - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - return factory.CreateClient(sectionName); - }); - return services; - } - - /// Registers a singleton ILmxProxyClient via builder action. - public static IServiceCollection AddLmxProxyClient( - this IServiceCollection services, Action configure) - { - services.AddSingleton(sp => - { - var builder = new LmxProxyClientBuilder(); - configure(builder); - return builder.Build(); - }); - return services; - } - - /// Registers a scoped ILmxProxyClient from the "LmxProxy" config section. - public static IServiceCollection AddScopedLmxProxyClient( - this IServiceCollection services, IConfiguration configuration) - { - services.AddSingleton( - sp => new LmxProxyClientFactory(configuration)); - services.AddScoped(sp => - { - var factory = sp.GetRequiredService(); - return factory.CreateClient(); - }); - return services; - } - - /// Registers a keyed singleton ILmxProxyClient. - public static IServiceCollection AddNamedLmxProxyClient( - this IServiceCollection services, string name, Action configure) - { - services.AddKeyedSingleton(name, (sp, key) => - { - var builder = new LmxProxyClientBuilder(); - configure(builder); - return builder.Build(); - }); - return services; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs deleted file mode 100644 index f0854b5..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Threading.Channels; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client; - -/// -/// Extension methods for streaming reads, writes, and subscriptions over ILmxProxyClient. -/// -public static class StreamingExtensions -{ - /// - /// Reads multiple tags as an async stream in batches. - /// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors. - /// - public static async IAsyncEnumerable> ReadStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - int batchSize = 100, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize)); - - var batch = new List(batchSize); - int consecutiveErrors = 0; - const int maxConsecutiveErrors = 3; - const int maxRetries = 2; - - foreach (string address in addresses) - { - cancellationToken.ThrowIfCancellationRequested(); - batch.Add(address); - - if (batch.Count >= batchSize) - { - bool success = false; - await foreach (var kvp in ReadBatchWithRetry( - client, batch, maxRetries, cancellationToken)) - { - consecutiveErrors = 0; - success = true; - yield return kvp; - } - if (!success) - { - consecutiveErrors++; - if (consecutiveErrors >= maxConsecutiveErrors) - yield break; - } - batch.Clear(); - } - } - - // Process remaining - if (batch.Count > 0) - { - await foreach (var kvp in ReadBatchWithRetry( - client, batch, maxRetries, cancellationToken)) - { - yield return kvp; - } - } - } - - private static async IAsyncEnumerable> ReadBatchWithRetry( - ILmxProxyClient client, - List batch, - int maxRetries, - [EnumeratorCancellation] CancellationToken ct) - { - int retries = 0; - while (retries <= maxRetries) - { - IDictionary? results = null; - try - { - results = await client.ReadBatchAsync(batch, ct); - } - catch when (retries < maxRetries) - { - retries++; - continue; - } - - if (results is not null) - { - foreach (var kvp in results) - yield return kvp; - yield break; - } - retries++; - } - } - - /// - /// Writes values from an async enumerable in batches. Returns total count written. - /// - public static async Task WriteStreamAsync( - this ILmxProxyClient client, - IAsyncEnumerable> values, - int batchSize = 100, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(values); - if (batchSize <= 0) - throw new ArgumentOutOfRangeException(nameof(batchSize)); - - var batch = new Dictionary(batchSize); - int totalWritten = 0; - - await foreach (var kvp in values.WithCancellation(cancellationToken)) - { - batch[kvp.Key] = kvp.Value; - - if (batch.Count >= batchSize) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - batch.Clear(); - } - } - - if (batch.Count > 0) - { - await client.WriteBatchAsync(batch, cancellationToken); - totalWritten += batch.Count; - } - - return totalWritten; - } - - /// - /// Processes items in parallel with a configurable max concurrency (default 4). - /// - public static async Task ProcessInParallelAsync( - this IAsyncEnumerable source, - Func processor, - int maxConcurrency = 4, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(processor); - if (maxConcurrency <= 0) - throw new ArgumentOutOfRangeException(nameof(maxConcurrency)); - - using var semaphore = new SemaphoreSlim(maxConcurrency); - var tasks = new List(); - - await foreach (T item in source.WithCancellation(cancellationToken)) - { - await semaphore.WaitAsync(cancellationToken); - - tasks.Add(Task.Run(async () => - { - try - { - await processor(item, cancellationToken); - } - finally - { - semaphore.Release(); - } - }, cancellationToken)); - } - - await Task.WhenAll(tasks); - } - - /// - /// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels. - /// - public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync( - this ILmxProxyClient client, - IEnumerable addresses, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(addresses); - - var channel = Channel.CreateBounded<(string, Vtq)>( - new BoundedChannelOptions(1000) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false - }); - - LmxProxyClient.ISubscription? subscription = null; - try - { - subscription = await client.SubscribeAsync( - addresses, - (tag, vtq) => - { - channel.Writer.TryWrite((tag, vtq)); - }, - ex => - { - channel.Writer.TryComplete(ex); - }, - cancellationToken); - - await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) - { - yield return item; - } - } - finally - { - subscription?.Dispose(); - channel.Writer.TryComplete(); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj deleted file mode 100644 index 0b81981..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - latest - enable - enable - ZB.MOM.WW.LmxProxy.Client - ZB.MOM.WW.LmxProxy.Client - true - true - gRPC client library for LmxProxy SCADA proxy service - AnyCPU - AnyCPU - - - - - - - - - - - - - - - - - - diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs deleted file mode 100644 index 27decec..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConfigurationValidator.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// - /// Validates the LmxProxy configuration at startup. - /// Throws InvalidOperationException on any validation error. - /// - public static class ConfigurationValidator - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); - - /// - /// Validates all configuration settings and logs the effective values. - /// Throws on first validation error. - /// - public static void ValidateAndLog(LmxProxyConfiguration config) - { - var errors = new List(); - - // GrpcPort - if (config.GrpcPort < 1 || config.GrpcPort > 65535) - errors.Add($"GrpcPort must be 1-65535, got {config.GrpcPort}"); - - // Connection - var conn = config.Connection; - if (conn.MonitorIntervalSeconds <= 0) - errors.Add($"Connection.MonitorIntervalSeconds must be > 0, got {conn.MonitorIntervalSeconds}"); - if (conn.ConnectionTimeoutSeconds <= 0) - errors.Add($"Connection.ConnectionTimeoutSeconds must be > 0, got {conn.ConnectionTimeoutSeconds}"); - if (conn.ReadTimeoutSeconds <= 0) - errors.Add($"Connection.ReadTimeoutSeconds must be > 0, got {conn.ReadTimeoutSeconds}"); - if (conn.WriteTimeoutSeconds <= 0) - errors.Add($"Connection.WriteTimeoutSeconds must be > 0, got {conn.WriteTimeoutSeconds}"); - if (conn.MaxConcurrentOperations <= 0) - errors.Add($"Connection.MaxConcurrentOperations must be > 0, got {conn.MaxConcurrentOperations}"); - if (conn.NodeName != null && conn.NodeName.Length > 255) - errors.Add("Connection.NodeName must be <= 255 characters"); - if (conn.GalaxyName != null && conn.GalaxyName.Length > 255) - errors.Add("Connection.GalaxyName must be <= 255 characters"); - - // Subscription - var sub = config.Subscription; - if (sub.ChannelCapacity < 0 || sub.ChannelCapacity > 100000) - errors.Add($"Subscription.ChannelCapacity must be 0-100000, got {sub.ChannelCapacity}"); - var validModes = new HashSet(StringComparer.OrdinalIgnoreCase) - { "DropOldest", "DropNewest", "Wait" }; - if (!validModes.Contains(sub.ChannelFullMode)) - errors.Add($"Subscription.ChannelFullMode must be DropOldest, DropNewest, or Wait, got '{sub.ChannelFullMode}'"); - - // ServiceRecovery - var sr = config.ServiceRecovery; - if (sr.FirstFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.FirstFailureDelayMinutes must be >= 0, got {sr.FirstFailureDelayMinutes}"); - if (sr.SecondFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.SecondFailureDelayMinutes must be >= 0, got {sr.SecondFailureDelayMinutes}"); - if (sr.SubsequentFailureDelayMinutes < 0) - errors.Add($"ServiceRecovery.SubsequentFailureDelayMinutes must be >= 0, got {sr.SubsequentFailureDelayMinutes}"); - if (sr.ResetPeriodDays <= 0) - errors.Add($"ServiceRecovery.ResetPeriodDays must be > 0, got {sr.ResetPeriodDays}"); - - // TLS - if (config.Tls.Enabled) - { - if (!File.Exists(config.Tls.ServerCertificatePath)) - Log.Warning("TLS enabled but server certificate not found at {Path} (will auto-generate)", - config.Tls.ServerCertificatePath); - if (!File.Exists(config.Tls.ServerKeyPath)) - Log.Warning("TLS enabled but server key not found at {Path} (will auto-generate)", - config.Tls.ServerKeyPath); - } - - // WebServer - if (config.WebServer.Enabled) - { - if (config.WebServer.Port < 1 || config.WebServer.Port > 65535) - errors.Add($"WebServer.Port must be 1-65535, got {config.WebServer.Port}"); - } - - if (errors.Count > 0) - { - foreach (var error in errors) - Log.Error("Configuration error: {Error}", error); - throw new InvalidOperationException( - $"Configuration validation failed with {errors.Count} error(s): {string.Join("; ", errors)}"); - } - - // Log effective configuration - Log.Information("Configuration validated successfully"); - Log.Information(" GrpcPort: {Port}", config.GrpcPort); - Log.Information(" ApiKeyConfigFile: {File}", config.ApiKeyConfigFile); - Log.Information(" Connection.AutoReconnect: {AutoReconnect}", conn.AutoReconnect); - Log.Information(" Connection.MonitorIntervalSeconds: {Interval}", conn.MonitorIntervalSeconds); - Log.Information(" Connection.MaxConcurrentOperations: {Max}", conn.MaxConcurrentOperations); - Log.Information(" Subscription.ChannelCapacity: {Capacity}", sub.ChannelCapacity); - Log.Information(" Subscription.ChannelFullMode: {Mode}", sub.ChannelFullMode); - Log.Information(" Tls.Enabled: {Enabled}", config.Tls.Enabled); - Log.Information(" WebServer.Enabled: {Enabled}, Port: {Port}", config.WebServer.Enabled, config.WebServer.Port); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs deleted file mode 100644 index d8a06df..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ConnectionConfiguration.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// MxAccess connection settings. - public class ConnectionConfiguration - { - /// Auto-reconnect check interval in seconds. Default: 5. - public int MonitorIntervalSeconds { get; set; } = 5; - - /// Initial connection timeout in seconds. Default: 30. - public int ConnectionTimeoutSeconds { get; set; } = 30; - - /// Per-read operation timeout in seconds. Default: 5. - public int ReadTimeoutSeconds { get; set; } = 5; - - /// Per-write operation timeout in seconds. Default: 5. - public int WriteTimeoutSeconds { get; set; } = 5; - - /// Semaphore limit for concurrent MxAccess operations. Default: 10. - public int MaxConcurrentOperations { get; set; } = 10; - - /// Enable auto-reconnect loop. Default: true. - public bool AutoReconnect { get; set; } = true; - - /// MxAccess node name (optional). - public string? NodeName { get; set; } - - /// MxAccess galaxy name (optional). - public string? GalaxyName { get; set; } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs deleted file mode 100644 index ee4b5bd..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/LmxProxyConfiguration.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Root configuration class bound to appsettings.json. - public class LmxProxyConfiguration - { - /// gRPC server listen port. Default: 50051. - public int GrpcPort { get; set; } = 50051; - - /// Path to API key configuration file. Default: apikeys.json. - public string ApiKeyConfigFile { get; set; } = "apikeys.json"; - - /// Unique client name for MxAccess Register(). Must be unique per instance. Default: auto-generated. - public string? ClientName { get; set; } - - /// MxAccess connection settings. - public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration(); - - /// Subscription channel settings. - public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration(); - - /// TLS/SSL settings. - public TlsConfiguration Tls { get; set; } = new TlsConfiguration(); - - /// Status web server settings. - public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration(); - - /// Windows SCM service recovery settings. - public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration(); - - /// Health check / active probe settings. - public HealthCheckConfiguration HealthCheck { get; set; } = new HealthCheckConfiguration(); - } - - /// Health check / probe configuration. - public class HealthCheckConfiguration - { - /// Tag address to subscribe to for connection liveness. Default: DevPlatform.Scheduler.ScanTime. - public string TestTagAddress { get; set; } = "DevPlatform.Scheduler.ScanTime"; - - /// - /// Maximum time (ms) without a value update on the test tag before forcing reconnect. - /// Default: 5000 (5 seconds). - /// - public int ProbeStaleThresholdMs { get; set; } = 5000; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs deleted file mode 100644 index af92c11..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/ServiceRecoveryConfiguration.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Windows SCM service recovery settings. - public class ServiceRecoveryConfiguration - { - /// Restart delay after first failure in minutes. Default: 1. - public int FirstFailureDelayMinutes { get; set; } = 1; - - /// Restart delay after second failure in minutes. Default: 5. - public int SecondFailureDelayMinutes { get; set; } = 5; - - /// Restart delay after subsequent failures in minutes. Default: 10. - public int SubsequentFailureDelayMinutes { get; set; } = 10; - - /// Days before failure count resets. Default: 1. - public int ResetPeriodDays { get; set; } = 1; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs deleted file mode 100644 index 346f738..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/SubscriptionConfiguration.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// Subscription channel settings. - public class SubscriptionConfiguration - { - /// Per-client subscription buffer size. Default: 1000. - public int ChannelCapacity { get; set; } = 1000; - - /// Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest. - public string ChannelFullMode { get; set; } = "DropOldest"; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs deleted file mode 100644 index cb432b4..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/TlsConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// TLS/SSL settings for the gRPC server. - public class TlsConfiguration - { - /// Enable TLS on the gRPC server. Default: false. - public bool Enabled { get; set; } = false; - - /// PEM server certificate path. Default: certs/server.crt. - public string ServerCertificatePath { get; set; } = "certs/server.crt"; - - /// PEM server private key path. Default: certs/server.key. - public string ServerKeyPath { get; set; } = "certs/server.key"; - - /// CA certificate for mutual TLS client validation. Default: certs/ca.crt. - public string ClientCaCertificatePath { get; set; } = "certs/ca.crt"; - - /// Require client certificates (mutual TLS). Default: false. - public bool RequireClientCertificate { get; set; } = false; - - /// Check certificate revocation lists. Default: false. - public bool CheckCertificateRevocation { get; set; } = false; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs deleted file mode 100644 index 9717b12..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Configuration/WebServerConfiguration.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Configuration -{ - /// HTTP status web server settings. - public class WebServerConfiguration - { - /// Enable the status web server. Default: true. - public bool Enabled { get; set; } = true; - - /// HTTP listen port. Default: 8080. - public int Port { get; set; } = 8080; - - /// Custom URL prefix (defaults to http://+:{Port}/ if null). - public string? Prefix { get; set; } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs deleted file mode 100644 index 741ee11..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Represents the state of a SCADA client connection. - /// - public enum ConnectionState - { - Disconnected, - Connecting, - Connected, - Disconnecting, - Error, - Reconnecting - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs deleted file mode 100644 index ace957b..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Event arguments for SCADA client connection state changes. - /// - public class ConnectionStateChangedEventArgs : EventArgs - { - public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState, - string? message = null) - { - PreviousState = previousState; - CurrentState = currentState; - Timestamp = DateTime.UtcNow; - Message = message; - } - - public ConnectionState PreviousState { get; } - public ConnectionState CurrentState { get; } - public DateTime Timestamp { get; } - public string? Message { get; } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs deleted file mode 100644 index 53b5622..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Interface for SCADA system clients (MxAccess wrapper). - /// - public interface IScadaClient : IAsyncDisposable - { - /// Gets whether the client is connected to MxAccess. - bool IsConnected { get; } - - /// Gets the current connection state. - ConnectionState ConnectionState { get; } - - /// Gets the UTC time when the current connection was established. - DateTime ConnectedSince { get; } - - /// Gets the number of times the client has reconnected since startup. - int ReconnectCount { get; } - - /// Occurs when the connection state changes. - event EventHandler ConnectionStateChanged; - - /// Connects to MxAccess. - Task ConnectAsync(CancellationToken ct = default); - - /// Disconnects from MxAccess. - Task DisconnectAsync(CancellationToken ct = default); - - /// Reads a single tag value. - /// VTQ with typed value. - Task ReadAsync(string address, CancellationToken ct = default); - - /// Reads multiple tag values with semaphore-controlled concurrency. - /// Dictionary of address to VTQ. - Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default); - - /// Writes a single tag value. Value is a native .NET type (not string). - Task WriteAsync(string address, object value, CancellationToken ct = default); - - /// Writes multiple tag values with semaphore-controlled concurrency. - Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default); - - /// - /// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires. - /// Returns (writeSuccess, flagReached, elapsedMs). - /// - /// Tag-value pairs to write. - /// Tag to poll after writes. - /// Expected value (type-aware comparison). - /// Max wait time in milliseconds. - /// Poll interval in milliseconds. - /// Cancellation token. - Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagTag, - object flagValue, - int timeoutMs, - int pollIntervalMs, - CancellationToken ct = default); - - /// - /// Unsubscribes specific tag addresses. Removes from stored subscriptions - /// and COM state. Safe to call after reconnect -- uses current handle mappings. - /// - Task UnsubscribeByAddressAsync(IEnumerable addresses); - - /// Subscribes to value changes for specified addresses. - /// Subscription handle for unsubscribing. - Task SubscribeAsync( - IEnumerable addresses, - Action callback, - CancellationToken ct = default); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/MxStatusMapper.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/MxStatusMapper.cs deleted file mode 100644 index c970b17..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/MxStatusMapper.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Maps MxAccess MXSTATUS_PROXY fields (detail, category, source) to - /// human-readable messages and OPC UA quality codes. - /// - public static class MxStatusMapper - { - // ── MxStatusDetail (short) → name + client message ────────── - - private static readonly Dictionary DetailCodes = - new Dictionary - { - { 0, ("MX_S_Success", "Success") }, - { 1, ("MX_E_RequestTimedOut", "Request to AVEVA System Platform timed out") }, - { 2, ("MX_E_PlatformCommunicationError", "Communication error with System Platform") }, - { 3, ("MX_E_InvalidPlatformId", "Invalid platform identifier") }, - { 4, ("MX_E_InvalidEngineId", "Invalid engine identifier") }, - { 5, ("MX_E_EngineCommunicationError", "Communication error with automation engine") }, - { 6, ("MX_E_InvalidReference", "Tag reference is invalid or could not be resolved") }, - { 7, ("MX_E_NoGalaxyRepository", "Galaxy repository is not available") }, - { 8, ("MX_E_InvalidObjectId", "Invalid object identifier") }, - { 9, ("MX_E_ObjectSignatureMismatch", "Object signature mismatch") }, - { 10, ("MX_E_AttributeSignatureMismatch", "Attribute signature mismatch") }, - { 11, ("MX_E_ResolvingAttribute", "Attribute is still being resolved") }, - { 12, ("MX_E_ResolvingObject", "Object is still being resolved") }, - { 13, ("MX_E_WrongDataType", "Value type does not match attribute data type") }, - { 14, ("MX_E_WrongNumberOfDimensions", "Wrong number of array dimensions") }, - { 15, ("MX_E_InvalidIndex", "Invalid array index") }, - { 16, ("MX_E_IndexOutOfOrder", "Array index out of order") }, - { 17, ("MX_E_DimensionDoesNotExist", "Array dimension does not exist") }, - { 18, ("MX_E_ConversionNotSupported", "Data type conversion not supported") }, - { 19, ("MX_E_UnableToConvertString", "Unable to convert string to target type") }, - { 20, ("MX_E_Overflow", "Numeric overflow during conversion") }, - { 21, ("MX_E_NmxVersionMismatch", "NMX version mismatch") }, - { 22, ("MX_E_NmxInvalidCommand", "NMX invalid command") }, - { 23, ("MX_E_LmxVersionMismatch", "LMX version mismatch") }, - { 24, ("MX_E_LmxInvalidCommand", "LMX invalid command") }, - { 25, ("MX_E_GalaxyRepositoryBusy", "Galaxy repository is busy") }, - { 26, ("MX_E_EngineOverloaded", "Automation engine is overloaded") }, - { 1000, ("MX_E_InvalidPrimitiveId", "Invalid primitive identifier") }, - { 1001, ("MX_E_InvalidAttributeId", "Invalid attribute identifier") }, - { 1002, ("MX_E_InvalidPropertyId", "Invalid property identifier") }, - { 1003, ("MX_E_IndexOutOfRange", "Array index out of range") }, - { 1004, ("MX_E_DataOutOfRange", "Data value out of range") }, - { 1005, ("MX_E_IncorrectDataType", "Incorrect data type for this attribute") }, - { 1006, ("MX_E_NotReadable", "Attribute is not readable") }, - { 1007, ("MX_E_NotWriteable", "Attribute is not writable") }, - { 1008, ("MX_E_WriteAccessDenied", "Write access denied — insufficient security") }, - { 1009, ("MX_E_UnknownError", "Unknown MxAccess error") }, - { 1010, ("MX_E_ObjectInitializing", "Object is still initializing") }, - { 1011, ("MX_E_EngineInitializing", "Automation engine is still initializing") }, - { 1012, ("MX_E_SecuredWrite", "Attribute requires secured write authentication") }, - { 1013, ("MX_E_VerifiedWrite", "Attribute requires verified write (two-user)") }, - { 1014, ("MX_E_NoAlarmAckPrivilege", "No alarm acknowledgment privilege") }, - { 8000, ("MX_E_AutomationObjectSpecificError", "Automation object specific error") }, - }; - - // ── MxStatusCategory (int) → name ────────── - - private static readonly Dictionary CategoryNames = new Dictionary - { - { -1, "Unknown" }, - { 0, "Ok" }, - { 1, "Pending" }, - { 2, "Warning" }, - { 3, "CommunicationError" }, - { 4, "ConfigurationError" }, - { 5, "OperationalError" }, - { 6, "SecurityError" }, - { 7, "SoftwareError" }, - { 8, "OtherError" }, - }; - - // ── MxStatusSource (int) → name ────────── - - private static readonly Dictionary SourceNames = new Dictionary - { - { -1, "Unknown" }, - { 0, "RequestingLmx" }, - { 1, "RespondingLmx" }, - { 2, "RequestingNmx" }, - { 3, "RespondingNmx" }, - { 4, "RequestingAutomationObject" }, - { 5, "RespondingAutomationObject" }, - }; - - /// - /// Gets the symbolic name for an MxStatusDetail code (e.g., "MX_E_WrongDataType"). - /// - public static string GetDetailName(int detailCode) - { - return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Name : string.Format("MX_E_Unknown({0})", detailCode); - } - - /// - /// Gets a human-readable client message for an MxStatusDetail code. - /// - public static string GetDetailMessage(int detailCode) - { - return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Message : string.Format("MxAccess error code {0}", detailCode); - } - - /// - /// Gets the symbolic name for an MxStatusCategory value. - /// - public static string GetCategoryName(int category) - { - return CategoryNames.TryGetValue(category, out var name) ? name : string.Format("Unknown({0})", category); - } - - /// - /// Gets the symbolic name for an MxStatusSource value. - /// - public static string GetSourceName(int source) - { - return SourceNames.TryGetValue(source, out var name) ? name : string.Format("Unknown({0})", source); - } - - /// - /// Builds a detailed error string from all MXSTATUS_PROXY fields. - /// Format: "MX_E_WrongDataType: Value type does not match attribute data type [Category=OperationalError, Source=RespondingAutomationObject]" - /// - public static string FormatStatus(int detail, int category, int source) - { - return string.Format("{0}: {1} [Category={2}, Source={3}]", - GetDetailName(detail), - GetDetailMessage(detail), - GetCategoryName(category), - GetSourceName(source)); - } - - /// - /// Maps an MxStatusCategory to the most appropriate OPC UA QualityCode. - /// Used when MXSTATUS_PROXY.success is false in an OnDataChange callback - /// to override the raw OPC DA quality byte. - /// - public static Quality CategoryToQuality(int category, int detail) - { - // Specific detail codes take priority - switch (detail) - { - case 6: // MX_E_InvalidReference - case 1001: // MX_E_InvalidAttributeId - return Quality.Bad_ConfigError; - case 2: // MX_E_PlatformCommunicationError - case 5: // MX_E_EngineCommunicationError - return Quality.Bad_CommFailure; - case 11: // MX_E_ResolvingAttribute - case 12: // MX_E_ResolvingObject - case 1010: // MX_E_ObjectInitializing - case 1011: // MX_E_EngineInitializing - return Quality.Bad_WaitingForInitialData; - case 1006: // MX_E_NotReadable - return Quality.Bad_OutOfService; - case 1: // MX_E_RequestTimedOut - return Quality.Bad_CommFailure; - } - - // Fall back to category - switch (category) - { - case 0: // MxCategoryOk - return Quality.Good; - case 1: // MxCategoryPending - return Quality.Uncertain; - case 2: // MxCategoryWarning - return Quality.Uncertain; - case 3: // MxCategoryCommunicationError - return Quality.Bad_CommFailure; - case 4: // MxCategoryConfigurationError - return Quality.Bad_ConfigError; - case 5: // MxCategoryOperationalError - return Quality.Bad; - case 6: // MxCategorySecurityError - return Quality.Bad; - case 7: // MxCategorySoftwareError - return Quality.Bad; - default: - return Quality.Bad; - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs deleted file mode 100644 index 3d5da4f..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/ProbeResult.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - public enum ProbeStatus - { - Healthy, - TransportFailure, - DataDegraded - } - - public sealed class ProbeResult - { - public ProbeStatus Status { get; } - public Quality? Quality { get; } - public DateTime? Timestamp { get; } - public string? Message { get; } - public Exception? Exception { get; } - - private ProbeResult(ProbeStatus status, Quality? quality, DateTime? timestamp, - string? message, Exception? exception) - { - Status = status; - Quality = quality; - Timestamp = timestamp; - Message = message; - Exception = exception; - } - - public static ProbeResult Healthy(Quality quality, DateTime timestamp) - => new ProbeResult(ProbeStatus.Healthy, quality, timestamp, null, null); - - public static ProbeResult Degraded(Quality quality, DateTime timestamp, string message) - => new ProbeResult(ProbeStatus.DataDegraded, quality, timestamp, message, null); - - public static ProbeResult TransportFailed(string message, Exception? ex = null) - => new ProbeResult(ProbeStatus.TransportFailure, null, null, message, ex); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs deleted file mode 100644 index dd230d5..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs +++ /dev/null @@ -1,127 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// OPC quality codes mapped to domain-level values. - /// The byte value matches the low-order byte of the OPC DA quality code, - /// enabling direct round-trip between the domain enum and the wire OPC DA byte. - /// - public enum Quality : byte - { - // ─────────────── Bad family (0-31) ─────────────── - /// 0x00 - Bad [Non-Specific] - Bad = 0, - - /// 0x01 - Unknown quality value - Unknown = 1, - - /// 0x04 - Bad [Configuration Error] - Bad_ConfigError = 4, - - /// 0x08 - Bad [Not Connected] - Bad_NotConnected = 8, - - /// 0x0C - Bad [Device Failure] - Bad_DeviceFailure = 12, - - /// 0x10 - Bad [Sensor Failure] - Bad_SensorFailure = 16, - - /// 0x14 - Bad [Last Known Value] - Bad_LastKnownValue = 20, - - /// 0x18 - Bad [Communication Failure] - Bad_CommFailure = 24, - - /// 0x1C - Bad [Out of Service] - Bad_OutOfService = 28, - - /// 0x20 - Bad [Waiting for Initial Data] - Bad_WaitingForInitialData = 32, - - // ──────────── Uncertain family (64-95) ─────────── - /// 0x40 - Uncertain [Non-Specific] - Uncertain = 64, - - /// 0x41 - Uncertain [Non-Specific] (Low Limited) - Uncertain_LowLimited = 65, - - /// 0x42 - Uncertain [Non-Specific] (High Limited) - Uncertain_HighLimited = 66, - - /// 0x43 - Uncertain [Non-Specific] (Constant) - Uncertain_Constant = 67, - - /// 0x44 - Uncertain [Last Usable] - Uncertain_LastUsable = 68, - - /// 0x45 - Uncertain [Last Usable] (Low Limited) - Uncertain_LastUsable_LL = 69, - - /// 0x46 - Uncertain [Last Usable] (High Limited) - Uncertain_LastUsable_HL = 70, - - /// 0x47 - Uncertain [Last Usable] (Constant) - Uncertain_LastUsable_Cnst = 71, - - /// 0x50 - Uncertain [Sensor Not Accurate] - Uncertain_SensorNotAcc = 80, - - /// 0x51 - Uncertain [Sensor Not Accurate] (Low Limited) - Uncertain_SensorNotAcc_LL = 81, - - /// 0x52 - Uncertain [Sensor Not Accurate] (High Limited) - Uncertain_SensorNotAcc_HL = 82, - - /// 0x53 - Uncertain [Sensor Not Accurate] (Constant) - Uncertain_SensorNotAcc_C = 83, - - /// 0x54 - Uncertain [EU Exceeded] - Uncertain_EuExceeded = 84, - - /// 0x55 - Uncertain [EU Exceeded] (Low Limited) - Uncertain_EuExceeded_LL = 85, - - /// 0x56 - Uncertain [EU Exceeded] (High Limited) - Uncertain_EuExceeded_HL = 86, - - /// 0x57 - Uncertain [EU Exceeded] (Constant) - Uncertain_EuExceeded_C = 87, - - /// 0x58 - Uncertain [Sub-Normal] - Uncertain_SubNormal = 88, - - /// 0x59 - Uncertain [Sub-Normal] (Low Limited) - Uncertain_SubNormal_LL = 89, - - /// 0x5A - Uncertain [Sub-Normal] (High Limited) - Uncertain_SubNormal_HL = 90, - - /// 0x5B - Uncertain [Sub-Normal] (Constant) - Uncertain_SubNormal_C = 91, - - // ─────────────── Good family (192-219) ──────────── - /// 0xC0 - Good [Non-Specific] - Good = 192, - - /// 0xC1 - Good [Non-Specific] (Low Limited) - Good_LowLimited = 193, - - /// 0xC2 - Good [Non-Specific] (High Limited) - Good_HighLimited = 194, - - /// 0xC3 - Good [Non-Specific] (Constant) - Good_Constant = 195, - - /// 0xD8 - Good [Local Override] - Good_LocalOverride = 216, - - /// 0xD9 - Good [Local Override] (Low Limited) - Good_LocalOverride_LL = 217, - - /// 0xDA - Good [Local Override] (High Limited) - Good_LocalOverride_HL = 218, - - /// 0xDB - Good [Local Override] (Constant) - Good_LocalOverride_C = 219 - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs deleted file mode 100644 index cae3c85..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Maps between the domain enum and proto QualityCode messages. - /// status_code (uint32) is canonical. symbolic_name is derived from a lookup table. - /// - public static class QualityCodeMapper - { - /// OPC UA status code → symbolic name lookup. - private static readonly Dictionary StatusCodeToName = new Dictionary - { - // Good - { 0x00000000, "Good" }, - { 0x00D80000, "GoodLocalOverride" }, - - // Uncertain - { 0x40900000, "UncertainLastUsableValue" }, - { 0x42390000, "UncertainSensorNotAccurate" }, - { 0x40540000, "UncertainEngineeringUnitsExceeded" }, - { 0x40580000, "UncertainSubNormal" }, - - // Bad - { 0x80000000, "Bad" }, - { 0x80040000, "BadConfigurationError" }, - { 0x808A0000, "BadNotConnected" }, - { 0x806B0000, "BadDeviceFailure" }, - { 0x806D0000, "BadSensorFailure" }, - { 0x80050000, "BadCommunicationFailure" }, - { 0x808F0000, "BadOutOfService" }, - { 0x80320000, "BadWaitingForInitialData" }, - }; - - /// Domain Quality enum → OPC UA status code. - private static readonly Dictionary QualityToStatusCode = new Dictionary - { - // Good family - { Quality.Good, 0x00000000 }, - { Quality.Good_LowLimited, 0x00000000 }, - { Quality.Good_HighLimited, 0x00000000 }, - { Quality.Good_Constant, 0x00000000 }, - { Quality.Good_LocalOverride, 0x00D80000 }, - { Quality.Good_LocalOverride_LL, 0x00D80000 }, - { Quality.Good_LocalOverride_HL, 0x00D80000 }, - { Quality.Good_LocalOverride_C, 0x00D80000 }, - - // Uncertain family - { Quality.Uncertain, 0x40900000 }, - { Quality.Uncertain_LowLimited, 0x40900000 }, - { Quality.Uncertain_HighLimited, 0x40900000 }, - { Quality.Uncertain_Constant, 0x40900000 }, - { Quality.Uncertain_LastUsable, 0x40900000 }, - { Quality.Uncertain_LastUsable_LL, 0x40900000 }, - { Quality.Uncertain_LastUsable_HL, 0x40900000 }, - { Quality.Uncertain_LastUsable_Cnst, 0x40900000 }, - { Quality.Uncertain_SensorNotAcc, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_LL, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_HL, 0x42390000 }, - { Quality.Uncertain_SensorNotAcc_C, 0x42390000 }, - { Quality.Uncertain_EuExceeded, 0x40540000 }, - { Quality.Uncertain_EuExceeded_LL, 0x40540000 }, - { Quality.Uncertain_EuExceeded_HL, 0x40540000 }, - { Quality.Uncertain_EuExceeded_C, 0x40540000 }, - { Quality.Uncertain_SubNormal, 0x40580000 }, - { Quality.Uncertain_SubNormal_LL, 0x40580000 }, - { Quality.Uncertain_SubNormal_HL, 0x40580000 }, - { Quality.Uncertain_SubNormal_C, 0x40580000 }, - - // Bad family - { Quality.Bad, 0x80000000 }, - { Quality.Unknown, 0x80000000 }, - { Quality.Bad_ConfigError, 0x80040000 }, - { Quality.Bad_NotConnected, 0x808A0000 }, - { Quality.Bad_DeviceFailure, 0x806B0000 }, - { Quality.Bad_SensorFailure, 0x806D0000 }, - { Quality.Bad_LastKnownValue, 0x80050000 }, - { Quality.Bad_CommFailure, 0x80050000 }, - { Quality.Bad_OutOfService, 0x808F0000 }, - { Quality.Bad_WaitingForInitialData, 0x80320000 }, - }; - - /// - /// Converts a domain Quality enum to a proto QualityCode message. - /// - public static Scada.QualityCode ToQualityCode(Quality quality) - { - var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u; - var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad"; - - return new Scada.QualityCode - { - StatusCode = statusCode, - SymbolicName = symbolicName - }; - } - - /// OPC UA status code → primary domain Quality (reverse lookup). - private static readonly Dictionary StatusCodeToQuality = new Dictionary - { - // Good - { 0x00000000, Quality.Good }, - { 0x00D80000, Quality.Good_LocalOverride }, - - // Uncertain — pick the most specific base variant - { 0x40900000, Quality.Uncertain_LastUsable }, - { 0x42390000, Quality.Uncertain_SensorNotAcc }, - { 0x40540000, Quality.Uncertain_EuExceeded }, - { 0x40580000, Quality.Uncertain_SubNormal }, - - // Bad - { 0x80000000, Quality.Bad }, - { 0x80040000, Quality.Bad_ConfigError }, - { 0x808A0000, Quality.Bad_NotConnected }, - { 0x806B0000, Quality.Bad_DeviceFailure }, - { 0x806D0000, Quality.Bad_SensorFailure }, - { 0x80050000, Quality.Bad_CommFailure }, - { 0x808F0000, Quality.Bad_OutOfService }, - { 0x80320000, Quality.Bad_WaitingForInitialData }, - }; - - /// - /// Converts an OPC UA status code (uint32) to a domain Quality enum. - /// Falls back to the nearest category if the specific code is not mapped. - /// - public static Quality FromStatusCode(uint statusCode) - { - if (StatusCodeToQuality.TryGetValue(statusCode, out var quality)) - return quality; - - // Category fallback - uint category = statusCode & 0xC0000000; - if (category == 0x00000000) return Quality.Good; - if (category == 0x40000000) return Quality.Uncertain; - return Quality.Bad; - } - - /// - /// Gets the symbolic name for a status code. - /// - public static string GetSymbolicName(uint statusCode) - { - if (StatusCodeToName.TryGetValue(statusCode, out var name)) - return name; - - uint category = statusCode & 0xC0000000; - if (category == 0x00000000) return "Good"; - if (category == 0x40000000) return "Uncertain"; - return "Bad"; - } - - /// - /// Creates a QualityCode for a specific well-known status. - /// - public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; - public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; - public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" }; - public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" }; - public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" }; - public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" }; - public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }; - public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" }; - public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" }; - public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" }; - public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs deleted file mode 100644 index 42d4f79..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Extension methods for the enum. - /// - public static class QualityExtensions - { - /// Returns true if quality is in the Good family (byte >= 192). - public static bool IsGood(this Quality q) => (byte)q >= 192; - - /// Returns true if quality is in the Uncertain family (byte 64-127). - public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128; - - /// Returns true if quality is in the Bad family (byte < 64). - public static bool IsBad(this Quality q) => (byte)q < 64; - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs deleted file mode 100644 index 6c6626f..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/SubscriptionStats.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// Subscription statistics for monitoring. - public class SubscriptionStats - { - public SubscriptionStats(int totalClients, int totalTags, int activeSubscriptions, - long totalDelivered = 0, long totalDropped = 0) - { - TotalClients = totalClients; - TotalTags = totalTags; - ActiveSubscriptions = activeSubscriptions; - TotalDelivered = totalDelivered; - TotalDropped = totalDropped; - } - - public int TotalClients { get; } - public int TotalTags { get; } - public int ActiveSubscriptions { get; } - public long TotalDelivered { get; } - public long TotalDropped { get; } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs deleted file mode 100644 index 1f7eb11..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueComparer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Type-aware equality comparison for WriteBatchAndWait flag matching. - /// - public static class TypedValueComparer - { - /// - /// Returns true if both values are the same type and equal. - /// Mismatched types are never equal. - /// Null equals null only. - /// - public new static bool Equals(object? a, object? b) - { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - if (a.GetType() != b.GetType()) return false; - - if (a is Array arrA && b is Array arrB) - { - if (arrA.Length != arrB.Length) return false; - for (int i = 0; i < arrA.Length; i++) - { - if (!object.Equals(arrA.GetValue(i), arrB.GetValue(i))) - return false; - } - return true; - } - - return object.Equals(a, b); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs deleted file mode 100644 index 50883fd..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using Google.Protobuf; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Converts between COM variant objects (boxed .NET types from MxAccess) - /// and proto-generated messages. - /// - public static class TypedValueConverter - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter)); - - /// - /// Converts a COM variant object to a proto TypedValue. - /// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL. - /// - public static Scada.TypedValue? ToTypedValue(object? value) - { - if (value == null || value is DBNull) - return null; - - switch (value) - { - case bool b: - return new Scada.TypedValue { BoolValue = b }; - - case short s: // VT_I2 → widened to int32 - return new Scada.TypedValue { Int32Value = s }; - - case int i: // VT_I4 - return new Scada.TypedValue { Int32Value = i }; - - case long l: // VT_I8 - return new Scada.TypedValue { Int64Value = l }; - - case ushort us: // VT_UI2 → widened to int32 - return new Scada.TypedValue { Int32Value = us }; - - case uint ui: // VT_UI4 → widened to int64 to avoid sign issues - return new Scada.TypedValue { Int64Value = ui }; - - case ulong ul: // VT_UI8 → int64, truncation risk - if (ul > (ulong)long.MaxValue) - Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul); - return new Scada.TypedValue { Int64Value = (long)ul }; - - case float f: // VT_R4 - return new Scada.TypedValue { FloatValue = f }; - - case double d: // VT_R8 - return new Scada.TypedValue { DoubleValue = d }; - - case string str: // VT_BSTR - return new Scada.TypedValue { StringValue = str }; - - case DateTime dt: // VT_DATE → UTC Ticks - return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks }; - - case decimal dec: // VT_DECIMAL → double (precision loss) - Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec); - return new Scada.TypedValue { DoubleValue = (double)dec }; - - case byte[] bytes: // VT_ARRAY of bytes - return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) }; - - case bool[] boolArr: - { - var arr = new Scada.BoolArray(); - arr.Values.AddRange(boolArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } }; - } - - case int[] intArr: - { - var arr = new Scada.Int32Array(); - arr.Values.AddRange(intArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } }; - } - - case long[] longArr: - { - var arr = new Scada.Int64Array(); - arr.Values.AddRange(longArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } }; - } - - case float[] floatArr: - { - var arr = new Scada.FloatArray(); - arr.Values.AddRange(floatArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } }; - } - - case double[] doubleArr: - { - var arr = new Scada.DoubleArray(); - arr.Values.AddRange(doubleArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } }; - } - - case string[] strArr: - { - var arr = new Scada.StringArray(); - arr.Values.AddRange(strArr); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } }; - } - - case DateTime[] dtArr: - { - var arr = new Scada.DatetimeArray(); - arr.Values.AddRange(Array.ConvertAll(dtArr, dt => dt.ToUniversalTime().Ticks)); - return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DatetimeValues = arr } }; - } - - default: - // VT_UNKNOWN or any unrecognized type — ToString() fallback - Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name); - return new Scada.TypedValue { StringValue = value.ToString() }; - } - } - - /// - /// Converts a proto TypedValue back to a boxed .NET object. - /// Returns null for unset oneof (null TypedValue or ValueCase.None). - /// - public static object? FromTypedValue(Scada.TypedValue? typedValue) - { - if (typedValue == null) - return null; - - switch (typedValue.ValueCase) - { - case Scada.TypedValue.ValueOneofCase.BoolValue: - return typedValue.BoolValue; - - case Scada.TypedValue.ValueOneofCase.Int32Value: - return typedValue.Int32Value; - - case Scada.TypedValue.ValueOneofCase.Int64Value: - return typedValue.Int64Value; - - case Scada.TypedValue.ValueOneofCase.FloatValue: - return typedValue.FloatValue; - - case Scada.TypedValue.ValueOneofCase.DoubleValue: - return typedValue.DoubleValue; - - case Scada.TypedValue.ValueOneofCase.StringValue: - return typedValue.StringValue; - - case Scada.TypedValue.ValueOneofCase.BytesValue: - return typedValue.BytesValue.ToByteArray(); - - case Scada.TypedValue.ValueOneofCase.DatetimeValue: - return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc); - - case Scada.TypedValue.ValueOneofCase.ArrayValue: - return FromArrayValue(typedValue.ArrayValue); - - case Scada.TypedValue.ValueOneofCase.None: - default: - return null; - } - } - - private static object? FromArrayValue(Scada.ArrayValue? arrayValue) - { - if (arrayValue == null) - return null; - - switch (arrayValue.ValuesCase) - { - case Scada.ArrayValue.ValuesOneofCase.BoolValues: - return arrayValue.BoolValues?.Values?.Count > 0 - ? ToArray(arrayValue.BoolValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.Int32Values: - return arrayValue.Int32Values?.Values?.Count > 0 - ? ToArray(arrayValue.Int32Values.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.Int64Values: - return arrayValue.Int64Values?.Values?.Count > 0 - ? ToArray(arrayValue.Int64Values.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.FloatValues: - return arrayValue.FloatValues?.Values?.Count > 0 - ? ToArray(arrayValue.FloatValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.DoubleValues: - return arrayValue.DoubleValues?.Values?.Count > 0 - ? ToArray(arrayValue.DoubleValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.StringValues: - return arrayValue.StringValues?.Values?.Count > 0 - ? ToArray(arrayValue.StringValues.Values) - : Array.Empty(); - - case Scada.ArrayValue.ValuesOneofCase.DatetimeValues: - if (arrayValue.DatetimeValues?.Values?.Count > 0) - { - var ticks = ToArray(arrayValue.DatetimeValues.Values); - var result = new DateTime[ticks.Length]; - for (int i = 0; i < ticks.Length; i++) - result[i] = new DateTime(ticks[i], DateTimeKind.Utc); - return result; - } - return Array.Empty(); - - default: - return null; - } - } - - private static T[] ToArray(Google.Protobuf.Collections.RepeatedField repeatedField) - { - var result = new T[repeatedField.Count]; - for (int i = 0; i < repeatedField.Count; i++) - result[i] = repeatedField[i]; - return result; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs deleted file mode 100644 index 8da3d68..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; - -namespace ZB.MOM.WW.LmxProxy.Host.Domain -{ - /// - /// Value, Timestamp, and Quality structure for SCADA data. - /// - public readonly struct Vtq : IEquatable - { - /// Gets the value. Null represents an unset/missing value. - public object? Value { get; } - - /// Gets the UTC timestamp when the value was read. - public DateTime Timestamp { get; } - - /// Gets the quality of the value. - public Quality Quality { get; } - - public Vtq(object? value, DateTime timestamp, Quality quality) - { - Value = value; - Timestamp = timestamp; - Quality = quality; - } - - public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality); - public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality); - public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good); - public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad); - public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain); - - public bool Equals(Vtq other) => - Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality; - - public override bool Equals(object obj) => obj is Vtq other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - int hashCode = Value != null ? Value.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ Timestamp.GetHashCode(); - hashCode = (hashCode * 397) ^ (int)Quality; - return hashCode; - } - } - - public override string ToString() => - $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}"; - - public static bool operator ==(Vtq left, Vtq right) => left.Equals(right); - public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto deleted file mode 100644 index 1da59c4..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto +++ /dev/null @@ -1,214 +0,0 @@ -syntax = "proto3"; -package scada; - -// ============================================================ -// Service Definition -// ============================================================ - -service ScadaService { - rpc Connect(ConnectRequest) returns (ConnectResponse); - rpc Disconnect(DisconnectRequest) returns (DisconnectResponse); - rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse); - rpc Read(ReadRequest) returns (ReadResponse); - rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse); - rpc Write(WriteRequest) returns (WriteResponse); - rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse); - rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse); - rpc Subscribe(SubscribeRequest) returns (stream VtqMessage); - rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse); -} - -// ============================================================ -// Typed Value System -// ============================================================ - -message TypedValue { - oneof value { - bool bool_value = 1; - int32 int32_value = 2; - int64 int64_value = 3; - float float_value = 4; - double double_value = 5; - string string_value = 6; - bytes bytes_value = 7; - int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01) - ArrayValue array_value = 9; - } -} - -message ArrayValue { - oneof values { - BoolArray bool_values = 1; - Int32Array int32_values = 2; - Int64Array int64_values = 3; - FloatArray float_values = 4; - DoubleArray double_values = 5; - StringArray string_values = 6; - DatetimeArray datetime_values = 7; // UTC DateTime.Ticks arrays - } -} - -message BoolArray { repeated bool values = 1; } -message Int32Array { repeated int32 values = 1; } -message Int64Array { repeated int64 values = 1; } -message FloatArray { repeated float values = 1; } -message DoubleArray { repeated double values = 1; } -message StringArray { repeated string values = 1; } -message DatetimeArray { repeated int64 values = 1; } // UTC DateTime.Ticks - -// ============================================================ -// OPC UA-Style Quality Codes -// ============================================================ - -message QualityCode { - uint32 status_code = 1; - string symbolic_name = 2; -} - -// ============================================================ -// Connection Lifecycle -// ============================================================ - -message ConnectRequest { - string client_id = 1; - string api_key = 2; -} - -message ConnectResponse { - bool success = 1; - string message = 2; - string session_id = 3; -} - -message DisconnectRequest { - string session_id = 1; -} - -message DisconnectResponse { - bool success = 1; - string message = 2; -} - -message GetConnectionStateRequest { - string session_id = 1; -} - -message GetConnectionStateResponse { - bool is_connected = 1; - string client_id = 2; - int64 connected_since_utc_ticks = 3; -} - -message CheckApiKeyRequest { - string api_key = 1; -} - -message CheckApiKeyResponse { - bool is_valid = 1; - string message = 2; -} - -// ============================================================ -// Value-Timestamp-Quality -// ============================================================ - -message VtqMessage { - string tag = 1; - TypedValue value = 2; - int64 timestamp_utc_ticks = 3; - QualityCode quality = 4; -} - -// ============================================================ -// Read Operations -// ============================================================ - -message ReadRequest { - string session_id = 1; - string tag = 2; -} - -message ReadResponse { - bool success = 1; - string message = 2; - VtqMessage vtq = 3; -} - -message ReadBatchRequest { - string session_id = 1; - repeated string tags = 2; -} - -message ReadBatchResponse { - bool success = 1; - string message = 2; - repeated VtqMessage vtqs = 3; -} - -// ============================================================ -// Write Operations -// ============================================================ - -message WriteRequest { - string session_id = 1; - string tag = 2; - TypedValue value = 3; -} - -message WriteResponse { - bool success = 1; - string message = 2; -} - -message WriteItem { - string tag = 1; - TypedValue value = 2; -} - -message WriteResult { - string tag = 1; - bool success = 2; - string message = 3; -} - -message WriteBatchRequest { - string session_id = 1; - repeated WriteItem items = 2; -} - -message WriteBatchResponse { - bool success = 1; - string message = 2; - repeated WriteResult results = 3; -} - -// ============================================================ -// WriteBatchAndWait -// ============================================================ - -message WriteBatchAndWaitRequest { - string session_id = 1; - repeated WriteItem items = 2; - string flag_tag = 3; - TypedValue flag_value = 4; - int32 timeout_ms = 5; - int32 poll_interval_ms = 6; -} - -message WriteBatchAndWaitResponse { - bool success = 1; - string message = 2; - repeated WriteResult write_results = 3; - bool flag_reached = 4; - int32 elapsed_ms = 5; -} - -// ============================================================ -// Subscription -// ============================================================ - -message SubscribeRequest { - string session_id = 1; - repeated string tags = 2; - int32 sampling_ms = 3; -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs deleted file mode 100644 index 7dec9f0..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs +++ /dev/null @@ -1,466 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Grpc.Core; -using GrpcStatus = Grpc.Core.Status; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Sessions; -using ZB.MOM.WW.LmxProxy.Host.Security; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services -{ - /// - /// gRPC service implementation for all 10 SCADA RPCs. - /// Inherits from proto-generated ScadaService.ScadaServiceBase. - /// - public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly SessionManager _sessionManager; - private readonly SubscriptionManager _subscriptionManager; - private readonly PerformanceMetrics? _performanceMetrics; - private readonly ApiKeyService? _apiKeyService; - - public ScadaGrpcService( - IScadaClient scadaClient, - SessionManager sessionManager, - SubscriptionManager subscriptionManager, - PerformanceMetrics? performanceMetrics = null, - ApiKeyService? apiKeyService = null) - { - _scadaClient = scadaClient; - _sessionManager = sessionManager; - _subscriptionManager = subscriptionManager; - _performanceMetrics = performanceMetrics; - _apiKeyService = apiKeyService; - } - - // -- Connection Management ------------------------------------ - - public override Task Connect( - Scada.ConnectRequest request, ServerCallContext context) - { - try - { - if (!_scadaClient.IsConnected) - { - return Task.FromResult(new Scada.ConnectResponse - { - Success = false, - Message = "MxAccess is not connected" - }); - } - - var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey); - - return Task.FromResult(new Scada.ConnectResponse - { - Success = true, - Message = "Connected", - SessionId = sessionId - }); - } - catch (Exception ex) - { - Log.Error(ex, "Connect failed for client {ClientId}", request.ClientId); - return Task.FromResult(new Scada.ConnectResponse - { - Success = false, - Message = ex.Message - }); - } - } - - public override Task Disconnect( - Scada.DisconnectRequest request, ServerCallContext context) - { - try - { - // Terminate session first — prevents new Subscribe RPCs from passing - // session validation while we clean up subscriptions - var terminated = _sessionManager.TerminateSession(request.SessionId); - - // Then clean up all subscriptions for this session - _subscriptionManager.UnsubscribeSession(request.SessionId); - - return Task.FromResult(new Scada.DisconnectResponse - { - Success = terminated, - Message = terminated ? "Disconnected" : "Session not found" - }); - } - catch (Exception ex) - { - Log.Error(ex, "Disconnect failed for session {SessionId}", request.SessionId); - return Task.FromResult(new Scada.DisconnectResponse - { - Success = false, - Message = ex.Message - }); - } - } - - public override Task GetConnectionState( - Scada.GetConnectionStateRequest request, ServerCallContext context) - { - var session = _sessionManager.GetSession(request.SessionId); - return Task.FromResult(new Scada.GetConnectionStateResponse - { - IsConnected = _scadaClient.IsConnected, - ClientId = session?.ClientId ?? "", - ConnectedSinceUtcTicks = session?.ConnectedSinceUtcTicks ?? 0 - }); - } - - // -- Read Operations ------------------------------------------ - - public override async Task Read( - Scada.ReadRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.ReadResponse - { - Success = false, - Message = "Invalid session", - Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.Bad()) - }; - } - - using var timing = _performanceMetrics?.BeginOperation("Read"); - try - { - var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken); - return new Scada.ReadResponse - { - Success = true, - Message = "", - Vtq = ConvertToProtoVtq(request.Tag, vtq) - }; - } - catch (Exception ex) - { - timing?.SetSuccess(false); - Log.Error(ex, "Read failed for tag {Tag}", request.Tag); - return new Scada.ReadResponse - { - Success = false, - Message = ex.Message, - Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.BadCommunicationFailure()) - }; - } - } - - public override async Task ReadBatch( - Scada.ReadBatchRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.ReadBatchResponse - { - Success = false, - Message = "Invalid session" - }; - } - - using var timing = _performanceMetrics?.BeginOperation("ReadBatch"); - try - { - var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken); - - var response = new Scada.ReadBatchResponse - { - Success = true, - Message = "" - }; - - // Return results in request order - foreach (var tag in request.Tags) - { - if (results.TryGetValue(tag, out var vtq)) - { - response.Vtqs.Add(ConvertToProtoVtq(tag, vtq)); - } - else - { - response.Vtqs.Add(CreateBadVtq(tag, QualityCodeMapper.BadConfigurationError())); - } - } - - return response; - } - catch (Exception ex) - { - timing?.SetSuccess(false); - Log.Error(ex, "ReadBatch failed"); - return new Scada.ReadBatchResponse - { - Success = false, - Message = ex.Message - }; - } - } - - // -- Write Operations ----------------------------------------- - - public override async Task Write( - Scada.WriteRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteResponse { Success = false, Message = "Invalid session" }; - } - - using var timing = _performanceMetrics?.BeginOperation("Write"); - try - { - var value = TypedValueConverter.FromTypedValue(request.Value); - await _scadaClient.WriteAsync(request.Tag, value!, context.CancellationToken); - return new Scada.WriteResponse { Success = true, Message = "" }; - } - catch (Exception ex) - { - timing?.SetSuccess(false); - Log.Error(ex, "Write failed for tag {Tag}", request.Tag); - return new Scada.WriteResponse { Success = false, Message = ex.Message }; - } - } - - public override async Task WriteBatch( - Scada.WriteBatchRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" }; - } - - using var timing = _performanceMetrics?.BeginOperation("WriteBatch"); - var response = new Scada.WriteBatchResponse { Success = true, Message = "" }; - - foreach (var item in request.Items) - { - try - { - var value = TypedValueConverter.FromTypedValue(item.Value); - await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); - response.Results.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = true, Message = "" - }); - } - catch (Exception ex) - { - response.Success = false; - response.Results.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = false, Message = ex.Message - }); - } - } - - if (!response.Success) - { - timing?.SetSuccess(false); - } - - return response; - } - - public override async Task WriteBatchAndWait( - Scada.WriteBatchAndWaitRequest request, ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - return new Scada.WriteBatchAndWaitResponse { Success = false, Message = "Invalid session" }; - } - - var response = new Scada.WriteBatchAndWaitResponse { Success = true }; - - try - { - // Execute writes and collect results - foreach (var item in request.Items) - { - try - { - var value = TypedValueConverter.FromTypedValue(item.Value); - await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken); - response.WriteResults.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = true, Message = "" - }); - } - catch (Exception ex) - { - response.Success = false; - response.Message = "One or more writes failed"; - response.WriteResults.Add(new Scada.WriteResult - { - Tag = item.Tag, Success = false, Message = ex.Message - }); - } - } - - // If any write failed, return immediately - if (!response.Success) - return response; - - // Poll flag tag - var flagValue = TypedValueConverter.FromTypedValue(request.FlagValue); - var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000; - var pollIntervalMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100; - - var sw = Stopwatch.StartNew(); - while (sw.ElapsedMilliseconds < timeoutMs) - { - context.CancellationToken.ThrowIfCancellationRequested(); - - var vtq = await _scadaClient.ReadAsync(request.FlagTag, context.CancellationToken); - if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue)) - { - response.FlagReached = true; - response.ElapsedMs = (int)sw.ElapsedMilliseconds; - return response; - } - - await Task.Delay(pollIntervalMs, context.CancellationToken); - } - - // Timeout -- not an error - response.FlagReached = false; - response.ElapsedMs = (int)sw.ElapsedMilliseconds; - return response; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Log.Error(ex, "WriteBatchAndWait failed"); - return new Scada.WriteBatchAndWaitResponse - { - Success = false, Message = ex.Message - }; - } - } - - // -- Subscription --------------------------------------------- - - public override async Task Subscribe( - Scada.SubscribeRequest request, - IServerStreamWriter responseStream, - ServerCallContext context) - { - if (!_sessionManager.ValidateSession(request.SessionId)) - { - throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid session")); - } - - var (reader, subscriptionId) = await _subscriptionManager.SubscribeAsync( - request.SessionId, request.Tags, context.CancellationToken); - - try - { - // Use a combined approach: check both the gRPC cancellation token AND - // periodic session validity. This works around Grpc.Core not reliably - // firing CancellationToken on client disconnect. - while (true) - { - // Wait for data with a timeout so we can periodically check session validity - using (var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30))) - using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - context.CancellationToken, timeoutCts.Token)) - { - bool hasData; - try - { - hasData = await reader.WaitToReadAsync(linkedCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested - && !context.CancellationToken.IsCancellationRequested) - { - // Timeout expired, not a client disconnect — check if session is still valid - if (!_sessionManager.ValidateSession(request.SessionId)) - { - Log.Information("Subscribe stream ending — session {SessionId} no longer valid", - request.SessionId); - break; - } - continue; // Session still valid, keep waiting - } - - if (!hasData) break; // Channel completed - - while (reader.TryRead(out var item)) - { - var protoVtq = ConvertToProtoVtq(item.address, item.vtq); - await responseStream.WriteAsync(protoVtq); - } - } - } - } - catch (OperationCanceledException) - { - // Client disconnected -- normal - } - catch (Exception ex) - { - Log.Error(ex, "Subscribe stream error for session {SessionId} subscription {SubscriptionId}", - request.SessionId, subscriptionId); - throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message)); - } - finally - { - // Clean up THIS subscription only, not the entire session - _subscriptionManager.UnsubscribeSubscription(subscriptionId); - } - } - - // -- API Key Check -------------------------------------------- - - public override Task CheckApiKey( - Scada.CheckApiKeyRequest request, ServerCallContext context) - { - // Check the API key from the request body against the key store. - var isValid = _apiKeyService != null && _apiKeyService.ValidateApiKey(request.ApiKey) != null; - return Task.FromResult(new Scada.CheckApiKeyResponse - { - IsValid = isValid, - Message = isValid ? "Valid" : "Invalid" - }); - } - - // -- Helpers -------------------------------------------------- - - /// Converts a domain Vtq to a proto VtqMessage. - private static Scada.VtqMessage ConvertToProtoVtq(string tag, Vtq vtq) - { - return new Scada.VtqMessage - { - Tag = tag, - Value = TypedValueConverter.ToTypedValue(vtq.Value), - TimestampUtcTicks = vtq.Timestamp.Ticks, - Quality = QualityCodeMapper.ToQualityCode(vtq.Quality) - }; - } - - /// Creates a VtqMessage with bad quality for error responses. - private static Scada.VtqMessage CreateBadVtq(string tag, Scada.QualityCode quality) - { - return new Scada.VtqMessage - { - Tag = tag, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = quality - }; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs deleted file mode 100644 index 9b8e3a9..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Health -{ - /// - /// Basic health check: connection state, success rate, client count. - /// - public class HealthCheckService : IHealthCheck - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - private readonly PerformanceMetrics _performanceMetrics; - - public HealthCheckService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics) - { - _scadaClient = scadaClient; - _subscriptionManager = subscriptionManager; - _performanceMetrics = performanceMetrics; - } - - public Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - try - { - var data = new Dictionary(); - - var isConnected = _scadaClient.IsConnected; - data["scada_connected"] = isConnected; - data["scada_connection_state"] = _scadaClient.ConnectionState.ToString(); - - var subscriptionStats = _subscriptionManager.GetStats(); - data["subscription_total_clients"] = subscriptionStats.TotalClients; - data["subscription_total_tags"] = subscriptionStats.TotalTags; - - long totalOperations = 0; - double totalSuccessRate = 0; - int operationCount = 0; - - foreach (var kvp in _performanceMetrics.GetAllMetrics()) - { - var stats = kvp.Value.GetStatistics(); - totalOperations += stats.TotalCount; - totalSuccessRate += stats.SuccessRate; - operationCount++; - } - - double averageSuccessRate = operationCount > 0 - ? totalSuccessRate / operationCount - : 1.0; - - data["total_operations"] = totalOperations; - data["average_success_rate"] = averageSuccessRate; - - if (!isConnected) - { - return Task.FromResult(HealthCheckResult.Unhealthy( - "SCADA client is not connected", data: data)); - } - - if (averageSuccessRate < 0.5 && totalOperations > 100) - { - return Task.FromResult(HealthCheckResult.Degraded( - "Average success rate is below 50%", data: data)); - } - - if (subscriptionStats.TotalClients > 100) - { - return Task.FromResult(HealthCheckResult.Degraded( - "High client count: " + subscriptionStats.TotalClients, data: data)); - } - - return Task.FromResult(HealthCheckResult.Healthy( - "LmxProxy is healthy", data: data)); - } - catch (Exception ex) - { - Logger.Error(ex, "Health check failed"); - return Task.FromResult(HealthCheckResult.Unhealthy( - "Health check failed: " + ex.Message, ex)); - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs deleted file mode 100644 index c724e39..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System; -using System.Threading; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; -using ZB.MOM.WW.LmxProxy.Host.Grpc.Services; -using ZB.MOM.WW.LmxProxy.Host.MxAccess; -using ZB.MOM.WW.LmxProxy.Host.Security; -using ZB.MOM.WW.LmxProxy.Host.Health; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Sessions; -using ZB.MOM.WW.LmxProxy.Host.Status; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - /// - /// Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue. - /// - public class LmxProxyService - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly LmxProxyConfiguration _config; - - private MxAccessClient? _mxAccessClient; - private SessionManager? _sessionManager; - private SubscriptionManager? _subscriptionManager; - private ApiKeyService? _apiKeyService; - private PerformanceMetrics? _performanceMetrics; - private HealthCheckService? _healthCheckService; - private StatusReportService? _statusReportService; - private StatusWebServer? _statusWebServer; - private Server? _grpcServer; - - public LmxProxyService(LmxProxyConfiguration config) - { - _config = config; - } - - /// - /// Topshelf Start callback. Creates and starts all components. - /// - public bool Start() - { - try - { - Log.Information("LmxProxy service starting..."); - - // 1. Validate configuration - ConfigurationValidator.ValidateAndLog(_config); - - // 2. Check/generate TLS certificates - var credentials = TlsCertificateManager.CreateServerCredentials(_config.Tls); - - // 3. Create ApiKeyService - _apiKeyService = new ApiKeyService(_config.ApiKeyConfigFile); - - // 4. Create MxAccessClient - _mxAccessClient = new MxAccessClient( - maxConcurrentOperations: _config.Connection.MaxConcurrentOperations, - readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds, - writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds, - monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds, - autoReconnect: _config.Connection.AutoReconnect, - nodeName: _config.Connection.NodeName, - galaxyName: _config.Connection.GalaxyName, - probeTestTagAddress: _config.HealthCheck.TestTagAddress, - probeStaleThresholdMs: _config.HealthCheck.ProbeStaleThresholdMs, - clientName: _config.ClientName); - - // 5. Connect to MxAccess synchronously (with timeout) - Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...", - _config.Connection.ConnectionTimeoutSeconds); - using (var cts = new CancellationTokenSource( - TimeSpan.FromSeconds(_config.Connection.ConnectionTimeoutSeconds))) - { - _mxAccessClient.ConnectAsync(cts.Token).GetAwaiter().GetResult(); - } - - // 6. Start auto-reconnect monitor - _mxAccessClient.StartMonitorLoop(); - - // 7. Create SubscriptionManager - var channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest; - if (_config.Subscription.ChannelFullMode.Equals("DropNewest", StringComparison.OrdinalIgnoreCase)) - channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropNewest; - else if (_config.Subscription.ChannelFullMode.Equals("Wait", StringComparison.OrdinalIgnoreCase)) - channelFullMode = System.Threading.Channels.BoundedChannelFullMode.Wait; - - _subscriptionManager = new SubscriptionManager( - _mxAccessClient, _config.Subscription.ChannelCapacity, channelFullMode); - - // Wire MxAccessClient data change events to SubscriptionManager - _mxAccessClient.OnTagValueChanged = _subscriptionManager.OnTagValueChanged; - - // Wire MxAccessClient disconnect to SubscriptionManager - _mxAccessClient.ConnectionStateChanged += (sender, e) => - { - if (e.CurrentState == Domain.ConnectionState.Disconnected || - e.CurrentState == Domain.ConnectionState.Error) - { - _subscriptionManager.NotifyDisconnection(); - } - else if (e.CurrentState == Domain.ConnectionState.Connected && - e.PreviousState == Domain.ConnectionState.Reconnecting) - { - _subscriptionManager.NotifyReconnection(); - } - }; - - // 8. Create SessionManager - _sessionManager = new SessionManager(inactivityTimeoutMinutes: 5); - _sessionManager.OnSessionScavenged(sessionId => - { - Log.Information("Cleaning up subscriptions for scavenged session {SessionId}", sessionId); - _subscriptionManager.UnsubscribeSession(sessionId); - }); - - // 9. Create performance metrics - _performanceMetrics = new PerformanceMetrics(); - - // 10. Create health check services - _healthCheckService = new HealthCheckService(_mxAccessClient, _subscriptionManager, _performanceMetrics); - - // 11. Create status report service - _statusReportService = new StatusReportService( - _mxAccessClient, _subscriptionManager, _performanceMetrics, - _healthCheckService); - - // 12. Start status web server - _statusWebServer = new StatusWebServer(_config.WebServer, _statusReportService); - if (!_statusWebServer.Start()) - { - Log.Warning("Status web server failed to start — continuing without it"); - } - - // 13. Create gRPC service - var grpcService = new ScadaGrpcService( - _mxAccessClient, _sessionManager, _subscriptionManager, _performanceMetrics, _apiKeyService); - - // 14. Create and configure interceptor - var interceptor = new ApiKeyInterceptor(_apiKeyService); - - // 15. Build and start gRPC server - _grpcServer = new Server - { - Services = - { - Scada.ScadaService.BindService(grpcService) - .Intercept(interceptor) - }, - Ports = - { - new ServerPort("0.0.0.0", _config.GrpcPort, credentials) - } - }; - - _grpcServer.Start(); - Log.Information("gRPC server started on port {Port}", _config.GrpcPort); - - Log.Information("LmxProxy service started successfully"); - return true; - } - catch (Exception ex) - { - Log.Fatal(ex, "LmxProxy service failed to start"); - return false; - } - } - - /// - /// Topshelf Stop callback. Stops and disposes all components in reverse order. - /// - public bool Stop() - { - Log.Information("LmxProxy service stopping..."); - - try - { - // 1. Stop reconnect monitor (5s wait) - _mxAccessClient?.StopMonitorLoop(); - - // 2. Stop status web server - _statusWebServer?.Stop(); - - // 3. Dispose performance metrics - _performanceMetrics?.Dispose(); - - // 4. Graceful gRPC shutdown (10s timeout, then kill) - if (_grpcServer != null) - { - Log.Information("Shutting down gRPC server..."); - _grpcServer.ShutdownAsync().Wait(TimeSpan.FromSeconds(10)); - Log.Information("gRPC server stopped"); - } - - // 3. Dispose components in reverse order - _subscriptionManager?.Dispose(); - _sessionManager?.Dispose(); - _apiKeyService?.Dispose(); - - // 4. Disconnect MxAccess (10s timeout) - if (_mxAccessClient != null) - { - Log.Information("Disconnecting from MxAccess..."); - _mxAccessClient.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10)); - Log.Information("MxAccess disconnected"); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error during shutdown"); - } - - Log.Information("LmxProxy service stopped"); - return true; - } - - /// Topshelf Pause callback -- no-op. - public bool Pause() - { - Log.Information("LmxProxy service paused (no-op)"); - return true; - } - - /// Topshelf Continue callback -- no-op. - public bool Continue() - { - Log.Information("LmxProxy service continued (no-op)"); - return true; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs deleted file mode 100644 index 8488297..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs +++ /dev/null @@ -1,205 +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.LmxProxy.Host.Metrics -{ - /// - /// Disposable scope returned by . - /// - public interface ITimingScope : IDisposable - { - void SetSuccess(bool success); - } - - /// - /// Statistics snapshot for a single operation type. - /// - public class MetricsStatistics - { - public long TotalCount { get; set; } - public long SuccessCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - public double Percentile95Milliseconds { get; set; } - } - - /// - /// Per-operation timing and success tracking with a rolling buffer for percentile computation. - /// - public class OperationMetrics - { - private readonly List _durations = new List(); - private readonly object _lock = new object(); - private long _totalCount; - private long _successCount; - private double _totalMilliseconds; - private double _minMilliseconds = double.MaxValue; - private double _maxMilliseconds; - - public void Record(TimeSpan duration, bool success) - { - 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); - } - } - } - - public MetricsStatistics GetStatistics() - { - lock (_lock) - { - if (_totalCount == 0) - { - return new MetricsStatistics(); - } - - var sortedDurations = _durations.OrderBy(d => d).ToList(); - var p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1; - p95Index = Math.Max(0, p95Index); - - return new MetricsStatistics - { - TotalCount = _totalCount, - SuccessCount = _successCount, - SuccessRate = (double)_successCount / _totalCount, - AverageMilliseconds = _totalMilliseconds / _totalCount, - MinMilliseconds = _minMilliseconds, - MaxMilliseconds = _maxMilliseconds, - Percentile95Milliseconds = sortedDurations[p95Index] - }; - } - } - } - - /// - /// Tracks per-operation performance metrics with periodic logging. - /// - public class PerformanceMetrics : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly ConcurrentDictionary _metrics - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly Timer _reportingTimer; - private bool _disposed; - - public PerformanceMetrics() - { - _reportingTimer = new Timer(ReportMetrics, null, - TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - - public void RecordOperation(string operationName, TimeSpan duration, bool success = true) - { - var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics()); - metrics.Record(duration, success); - } - - public ITimingScope BeginOperation(string operationName) - { - return new TimingScope(this, operationName); - } - - public OperationMetrics? GetMetrics(string operationName) - { - return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null; - } - - public IReadOnlyDictionary GetAllMetrics() - { - return _metrics; - } - - 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); - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _reportingTimer.Dispose(); - ReportMetrics(null); - } - - /// - /// Disposable timing scope that records duration on dispose. - /// - private class TimingScope : ITimingScope - { - private readonly PerformanceMetrics _metrics; - private readonly string _operationName; - private readonly Stopwatch _stopwatch; - private bool _success = true; - private bool _disposed; - - public TimingScope(PerformanceMetrics metrics, string operationName) - { - _metrics = metrics; - _operationName = operationName; - _stopwatch = Stopwatch.StartNew(); - } - - public void SetSuccess(bool success) - { - _success = success; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _stopwatch.Stop(); - _metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success); - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs deleted file mode 100644 index 918ac9c..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Connection.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Connects to MxAccess on the dedicated STA thread. - /// - public async Task ConnectAsync(CancellationToken ct = default) - { - if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient)); - if (IsConnected) return; - - SetState(ConnectionState.Connecting); - - try - { - await _staThread.RunAsync(() => ConnectInternal()); - - lock (_lock) - { - _connectedSince = DateTime.UtcNow; - } - - SetState(ConnectionState.Connected); - Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle); - - // Recreate any stored subscriptions from a previous connection - await RecreateStoredSubscriptionsAsync(); - - // Start persistent probe subscription - await StartProbeSubscriptionAsync(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to connect to MxAccess"); - await CleanupComObjectsAsync(); - SetState(ConnectionState.Error, ex.Message); - throw; - } - } - - /// - /// Disconnects from MxAccess on the dedicated STA thread. - /// - public async Task DisconnectAsync(CancellationToken ct = default) - { - if (!IsConnected) return; - - SetState(ConnectionState.Disconnecting); - - try - { - await _staThread.RunAsync(() => DisconnectInternal()); - - SetState(ConnectionState.Disconnected); - Log.Information("Disconnected from MxAccess"); - } - catch (Exception ex) - { - Log.Error(ex, "Error during disconnect"); - SetState(ConnectionState.Error, ex.Message); - } - } - - /// - /// Starts the auto-reconnect monitor loop. - /// Call this after initial ConnectAsync succeeds. - /// - public void StartMonitorLoop() - { - if (!_autoReconnect) return; - - _reconnectCts = new CancellationTokenSource(); - Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token)); - } - - /// - /// Stops the auto-reconnect monitor loop. - /// - public void StopMonitorLoop() - { - _reconnectCts?.Cancel(); - } - - /// Gets the UTC time when the connection was established. - public DateTime ConnectedSince - { - get { lock (_lock) { return _connectedSince; } } - } - - /// Gets the number of times the client has reconnected since startup. - public int ReconnectCount => _reconnectCount; - - // ── Internal synchronous methods ────────── - - private void ConnectInternal() - { - lock (_lock) - { - // Create COM object - _lmxProxy = new LMXProxyServer(); - - // Wire event handlers - _lmxProxy.OnDataChange += OnDataChange; - _lmxProxy.OnWriteComplete += OnWriteComplete; - - // Register with MxAccess using unique client name - _connectionHandle = _lmxProxy.Register(_clientName); - Log.Information("Registered with MxAccess as '{ClientName}'", _clientName); - - if (_connectionHandle <= 0) - { - throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned"); - } - } - } - - private void DisconnectInternal() - { - lock (_lock) - { - if (_lmxProxy == null || _connectionHandle <= 0) return; - - try - { - // Unadvise all active subscriptions before unregistering - foreach (var kvp in new Dictionary(_addressToHandle)) - { - try - { - _lmxProxy.UnAdvise(_connectionHandle, kvp.Value); - _lmxProxy.RemoveItem(_connectionHandle, kvp.Value); - } - catch (Exception ex) - { - Log.Debug(ex, "Error removing subscription for {Address} during disconnect", kvp.Key); - } - } - - // Remove event handlers - _lmxProxy.OnDataChange -= OnDataChange; - _lmxProxy.OnWriteComplete -= OnWriteComplete; - - // Unregister - _lmxProxy.Unregister(_connectionHandle); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during MxAccess unregister"); - } - finally - { - // Force-release COM object - try - { - Marshal.ReleaseComObject(_lmxProxy); - } - catch { } - - _lmxProxy = null; - _connectionHandle = 0; - - // Clear handle tracking (but keep _storedSubscriptions for reconnect) - _handleToAddress.Clear(); - _addressToHandle.Clear(); - _pendingWrites.Clear(); - } - } - } - - /// - /// Subscribes to the configured probe test tag so that OnDataChange - /// callbacks update . Called after - /// connect (and reconnect). The subscription is stored for reconnect - /// replay like any other subscription. - /// - private async Task StartProbeSubscriptionAsync() - { - if (_probeTestTagAddress == null) return; - - _lastProbeValueTime = DateTime.UtcNow; - - await _staThread.RunAsync(() => - { - lock (_lock) - { - if (!IsConnected || _lmxProxy == null) return; - - // Subscribe (skips if already subscribed from reconnect replay) - SubscribeInternal(_probeTestTagAddress); - - // Store a no-op callback — the real work happens in OnProbeDataChange - // which is called from OnDataChange before the stored callback - _storedSubscriptions[_probeTestTagAddress] = (_, __) => { }; - } - }); - - Log.Information("Probe subscription started for {Tag} (stale threshold={ThresholdMs}ms)", - _probeTestTagAddress, _probeStaleThresholdMs); - } - - /// - /// Called from when a value arrives for the probe tag. - /// Updates the last-seen timestamp so the monitor loop can detect staleness. - /// - internal void OnProbeDataChange(string address, Vtq vtq) - { - _lastProbeValueTime = DateTime.UtcNow; - } - - /// - /// Auto-reconnect monitor loop with persistent subscription probe. - /// - If disconnected: attempt reconnect. - /// - If connected and probe configured: check time since last probe value update. - /// If stale beyond threshold, force disconnect and reconnect. - /// - private async Task MonitorConnectionAsync(CancellationToken ct) - { - Log.Information("Connection monitor loop started (interval={IntervalMs}ms, probe={ProbeEnabled}, staleThreshold={StaleMs}ms)", - _monitorIntervalMs, _probeTestTagAddress != null, _probeStaleThresholdMs); - - while (!ct.IsCancellationRequested) - { - try - { - await Task.Delay(_monitorIntervalMs, ct); - } - catch (OperationCanceledException) - { - break; - } - - // -- Case 1: Already disconnected -- - if (!IsConnected) - { - await AttemptReconnectAsync(ct); - // Reset probe timer so the next check gives the new connection - // a full interval to deliver its first OnDataChange callback - _lastProbeValueTime = DateTime.UtcNow; - continue; - } - - // -- Case 2: Connected, no probe configured -- - if (_probeTestTagAddress == null) - continue; - - // -- Case 3: Connected, check probe staleness -- - var elapsed = DateTime.UtcNow - _lastProbeValueTime; - if (elapsed.TotalMilliseconds > _probeStaleThresholdMs) - { - Log.Warning("Probe tag {Tag} stale for {ElapsedMs}ms (threshold={ThresholdMs}ms) — forcing reconnect", - _probeTestTagAddress, (int)elapsed.TotalMilliseconds, _probeStaleThresholdMs); - - try - { - await DisconnectAsync(ct); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during forced disconnect before reconnect"); - } - - await AttemptReconnectAsync(ct); - _lastProbeValueTime = DateTime.UtcNow; - } - } - - Log.Information("Connection monitor loop exited"); - } - - private async Task AttemptReconnectAsync(CancellationToken ct) - { - Log.Information("Attempting reconnect..."); - SetState(ConnectionState.Reconnecting); - - try - { - await ConnectAsync(ct); - Interlocked.Increment(ref _reconnectCount); - Log.Information("Reconnected to MxAccess successfully (reconnect #{Count})", _reconnectCount); - } - catch (OperationCanceledException) - { - // Let the outer loop handle cancellation - } - catch (Exception ex) - { - Log.Warning(ex, "Reconnect attempt failed, will retry at next interval"); - } - } - - /// - /// Cleans up COM objects on the dedicated STA thread after a failed connection. - /// - private async Task CleanupComObjectsAsync() - { - try - { - await _staThread.RunAsync(() => - { - lock (_lock) - { - if (_lmxProxy != null) - { - try { _lmxProxy.OnDataChange -= OnDataChange; } catch { } - try { _lmxProxy.OnWriteComplete -= OnWriteComplete; } catch { } - try { Marshal.ReleaseComObject(_lmxProxy); } catch { } - _lmxProxy = null; - } - _connectionHandle = 0; - _handleToAddress.Clear(); - _addressToHandle.Clear(); - _pendingWrites.Clear(); - } - }); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during COM object cleanup"); - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs deleted file mode 100644 index 9c05593..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.EventHandlers.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Callback invoked by the SubscriptionManager when it needs to deliver - /// data change events. Set by the SubscriptionManager during initialization. - /// - public Action? OnTagValueChanged { get; set; } - - /// - /// COM event handler for MxAccess OnDataChange events. - /// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface. - /// - private void OnDataChange( - int hLMXServerHandle, - int phItemHandle, - object pvItemValue, - int pwItemQuality, - object pftItemTimeStamp, - ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - var quality = MapQuality(pwItemQuality); - var timestamp = ConvertTimestamp(pftItemTimeStamp); - - // Check MXSTATUS_PROXY — if success is false, override quality - // with a more specific code derived from the MxAccess status fields - if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0) - { - var status = ItemStatus[0]; - quality = MxStatusMapper.CategoryToQuality((int)status.category, status.detail); - Log.Debug("OnDataChange status failure for handle {Handle}: {Status}", - phItemHandle, MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy)); - } - - var vtq = new Vtq(pvItemValue, timestamp, quality); - - // Resolve address from handle map - string address; - lock (_lock) - { - if (!_handleToAddress.TryGetValue(phItemHandle, out address)) - { - Log.Debug("OnDataChange for unknown handle {Handle}, ignoring", phItemHandle); - return; - } - } - - // Invoke the stored subscription callback - Action callback; - lock (_lock) - { - if (!_storedSubscriptions.TryGetValue(address, out callback)) - { - Log.Debug("OnDataChange for {Address} but no callback registered", address); - return; - } - } - - // Update probe timestamp if this is the probe tag - if (_probeTestTagAddress != null && - string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase)) - { - OnProbeDataChange(address, vtq); - } - - callback.Invoke(address, vtq); - - // Also route to the SubscriptionManager's global handler - OnTagValueChanged?.Invoke(address, vtq); - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnDataChange event for handle {Handle}", phItemHandle); - } - } - - /// - /// COM event handler for MxAccess OnWriteComplete events. - /// Resolves the pending TaskCompletionSource so the caller gets - /// confirmation (or error) from the OnWriteComplete callback. - /// - private void OnWriteComplete( - int hLMXServerHandle, - int phItemHandle, - ref MXSTATUS_PROXY[] ItemStatus) - { - try - { - TaskCompletionSource tcs; - bool hasPending; - lock (_lock) - { - hasPending = _pendingWrites.TryGetValue(phItemHandle, out tcs); - } - - if (ItemStatus != null && ItemStatus.Length > 0) - { - var status = ItemStatus[0]; - if (status.success == 0) - { - string errorMsg = MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy); - Log.Warning("OnWriteComplete: write failed for handle {Handle}: {Status}", phItemHandle, errorMsg); - if (hasPending) tcs.TrySetException(new InvalidOperationException("Write failed: " + errorMsg)); - } - else - { - Log.Debug("OnWriteComplete: write succeeded for handle {Handle}", phItemHandle); - if (hasPending) tcs.TrySetResult(true); - } - } - else - { - Log.Debug("OnWriteComplete: no status for handle {Handle}", phItemHandle); - tcs?.TrySetResult(true); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error processing OnWriteComplete event for handle {Handle}", phItemHandle); - } - } - - /// - /// Converts a timestamp object to DateTime in UTC. - /// - private static DateTime ConvertTimestamp(object timestamp) - { - if (timestamp is DateTime dt) - { - return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); - } - - return DateTime.UtcNow; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs deleted file mode 100644 index 4e1e8cd..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.ReadWrite.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Reads a single tag value from MxAccess. - /// Uses subscribe-get-first-value-unsubscribe pattern (same as v1). - /// - public async Task ReadAsync(string address, CancellationToken ct = default) - { - if (!IsConnected) - return Vtq.New(null, Quality.Bad_NotConnected); - - await _readSemaphore.WaitAsync(ct); - try - { - return await ReadSingleValueAsync(address, ct); - } - catch (System.Runtime.InteropServices.COMException comEx) - { - Log.Error(comEx, "COM read error for tag {Address}: HRESULT=0x{ErrorCode:X8}", address, comEx.ErrorCode); - return Vtq.New(null, Quality.Bad_CommFailure); - } - catch (TimeoutException) - { - Log.Warning("Read timed out for tag {Address}", address); - return Vtq.New(null, Quality.Bad_CommFailure); - } - catch (Exception ex) - { - Log.Error(ex, "ReadAsync failed for tag {Address}", address); - return Vtq.New(null, Quality.Bad_CommFailure); - } - finally - { - _readSemaphore.Release(); - } - } - - /// - /// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent). - /// Each tag is read independently. Partial failures return Bad quality for failed tags. - /// - public async Task> ReadBatchAsync( - IEnumerable addresses, CancellationToken ct = default) - { - var addressList = addresses.ToList(); - var results = new Dictionary(addressList.Count, StringComparer.OrdinalIgnoreCase); - - var tasks = addressList.Select(async address => - { - var vtq = await ReadAsync(address, ct); - return (address, vtq); - }); - - foreach (var task in await Task.WhenAll(tasks)) - { - results[task.address] = task.vtq; - } - - return results; - } - - /// - /// Writes a single tag value to MxAccess. - /// Uses Task.Run for COM calls. Write completes synchronously (fire-and-forget). - /// - public async Task WriteAsync(string address, object value, CancellationToken ct = default) - { - if (!IsConnected) - throw new InvalidOperationException("Not connected to MxAccess"); - - await _writeSemaphore.WaitAsync(ct); - try - { - await WriteInternalAsync(address, value, ct); - } - finally - { - _writeSemaphore.Release(); - } - } - - /// - /// Writes multiple tag values with semaphore-controlled concurrency. - /// - public async Task WriteBatchAsync( - IReadOnlyDictionary values, CancellationToken ct = default) - { - var tasks = values.Select(async kvp => - { - await WriteAsync(kvp.Key, kvp.Value, ct); - }); - - await Task.WhenAll(tasks); - } - - /// - /// Writes a batch, then polls flagTag until it equals flagValue or timeout expires. - /// Uses type-aware comparison via TypedValueComparer. - /// - public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, - string flagTag, - object flagValue, - int timeoutMs, - int pollIntervalMs, - CancellationToken ct = default) - { - // Write all values first - await WriteBatchAsync(values, ct); - - // Poll flag tag - var sw = System.Diagnostics.Stopwatch.StartNew(); - var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000; - var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100; - - while (sw.ElapsedMilliseconds < effectiveTimeout) - { - ct.ThrowIfCancellationRequested(); - - var vtq = await ReadAsync(flagTag, ct); - if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue)) - { - return (true, (int)sw.ElapsedMilliseconds); - } - - await Task.Delay(effectiveInterval, ct); - } - - return (false, (int)sw.ElapsedMilliseconds); - } - - // ── Private read/write helpers ────────── - - /// - /// Reads a single value by subscribing, waiting for the first data change callback, - /// then unsubscribing. This is the same pattern as v1. - /// - private async Task ReadSingleValueAsync(string address, CancellationToken ct) - { - var tcs = new TaskCompletionSource(); - IAsyncDisposable? subscription = null; - - try - { - subscription = await SubscribeAsync( - new[] { address }, - (addr, vtq) => { tcs.TrySetResult(vtq); }, - ct); - - return await WaitForReadResultAsync(tcs, ct); - } - finally - { - if (subscription != null) - { - await subscription.DisposeAsync(); - } - } - } - - /// - /// Waits for a read result with timeout. - /// - private async Task WaitForReadResultAsync(TaskCompletionSource tcs, CancellationToken ct) - { - using (var cts = new CancellationTokenSource(_readTimeoutMs)) - using (ct.Register(() => cts.Cancel())) - { - cts.Token.Register(() => tcs.TrySetException( - new TimeoutException("Read timeout"))); - return await tcs.Task; - } - } - - /// - /// Internal write implementation dispatched on the STA thread. - /// Registers a TaskCompletionSource, calls Write(), then awaits the - /// OnWriteComplete callback via the STA message pump. Falls back to - /// fire-and-forget if the callback doesn't arrive within the timeout. - /// - private async Task WriteInternalAsync(string address, object value, CancellationToken ct) - { - var tcs = new TaskCompletionSource(); - int itemHandle = 0; - - // Step 1: Setup and write on the STA thread - await _staThread.RunAsync(() => - { - lock (_lock) - { - if (!IsConnected || _lmxProxy == null) - throw new InvalidOperationException("Not connected to MxAccess"); - - try - { - itemHandle = _lmxProxy.AddItem(_connectionHandle, address); - _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle); - - // Register for OnWriteComplete callback - _pendingWrites[itemHandle] = tcs; - - // Write the value (-1 = no security classification) - _lmxProxy.Write(_connectionHandle, itemHandle, value, -1); - - Log.Debug("Write dispatched for {Address} (handle={Handle}), awaiting OnWriteComplete", - address, itemHandle); - } - catch (System.Runtime.InteropServices.COMException comEx) - { - _pendingWrites.Remove(itemHandle); - string enriched = string.Format("Write failed for '{0}': COM error 0x{1:X8} — {2}", - address, comEx.ErrorCode, comEx.Message); - Log.Error(comEx, "COM write error for {Address}: HRESULT=0x{ErrorCode:X8}", - address, comEx.ErrorCode); - throw new InvalidOperationException(enriched, comEx); - } - catch (Exception ex) - { - _pendingWrites.Remove(itemHandle); - Log.Error(ex, "Failed to write value to {Address}", address); - throw; - } - } - }); - - // Step 2: Wait for OnWriteComplete callback (delivered via STA message pump) - try - { - using (var cts = new CancellationTokenSource(_writeTimeoutMs)) - using (ct.Register(() => cts.Cancel())) - { - cts.Token.Register(() => tcs.TrySetResult(true)); // timeout = assume success (fire-and-forget fallback) - await tcs.Task; - } - } - finally - { - // Step 3: Clean up on the STA thread - if (itemHandle > 0) - { - try - { - await _staThread.RunAsync(() => - { - lock (_lock) - { - _pendingWrites.Remove(itemHandle); - if (_lmxProxy != null && _connectionHandle > 0) - { - try - { - _lmxProxy.UnAdvise(_connectionHandle, itemHandle); - _lmxProxy.RemoveItem(_connectionHandle, itemHandle); - } - catch (Exception ex) - { - Log.Debug(ex, "Error cleaning up write item for {Address} (handle={Handle})", address, itemHandle); - } - } - } - }); - } - catch (Exception ex) - { - Log.Debug(ex, "Error dispatching write cleanup for {Address}", address); - } - } - } - } - - /// - /// Maps an MxAccess OPC DA quality integer to the domain Quality enum. - /// - private static Quality MapQuality(int opcDaQuality) - { - if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality)) - return (Quality)(byte)opcDaQuality; - - // Fallback: use category bits - if (opcDaQuality >= 192) return Quality.Good; - if (opcDaQuality >= 64) return Quality.Uncertain; - return Quality.Bad; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs deleted file mode 100644 index ba2a7cc..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.Subscription.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - public sealed partial class MxAccessClient - { - /// - /// Subscribes to value changes for the specified addresses. - /// Stores subscription state for reconnect replay. - /// COM calls dispatched on the dedicated STA thread. - /// - public async Task SubscribeAsync( - IEnumerable addresses, - Action callback, - CancellationToken ct = default) - { - if (!IsConnected) - throw new InvalidOperationException("Not connected to MxAccess"); - - var addressList = addresses.ToList(); - - await _staThread.RunAsync(() => - { - lock (_lock) - { - if (!IsConnected || _lmxProxy == null) - throw new InvalidOperationException("Not connected to MxAccess"); - - foreach (var address in addressList) - { - SubscribeInternal(address); - - // Store for reconnect replay (but don't overwrite the probe tag's callback) - if (_probeTestTagAddress == null || - !string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase)) - { - _storedSubscriptions[address] = callback; - } - } - } - }); - - Log.Information("Subscribed to {Count} tags", addressList.Count); - - return new SubscriptionHandle(this, addressList, callback); - } - - /// - /// Unsubscribes specific addresses by address name. - /// Removes from both COM state and stored subscriptions (no reconnect replay). - /// - public async Task UnsubscribeByAddressAsync(IEnumerable addresses) - { - await UnsubscribeAsync(addresses); - } - - /// - /// Unsubscribes specific addresses. - /// - internal async Task UnsubscribeAsync(IEnumerable addresses) - { - var addressList = addresses.ToList(); - - await _staThread.RunAsync(() => - { - lock (_lock) - { - foreach (var address in addressList) - { - UnsubscribeInternal(address); - - // Don't remove probe tag from stored subscriptions — it's permanent - if (_probeTestTagAddress == null || - !string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase)) - { - _storedSubscriptions.Remove(address); - } - } - } - }); - - Log.Information("Unsubscribed from {Count} tags", addressList.Count); - } - - /// - /// Recreates all stored subscriptions after a reconnect. - /// Does not re-store them (they're already stored). - /// - private async Task RecreateStoredSubscriptionsAsync() - { - Dictionary> subscriptions; - lock (_lock) - { - if (_storedSubscriptions.Count == 0) return; - subscriptions = new Dictionary>(_storedSubscriptions); - } - - Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count); - - await _staThread.RunAsync(() => - { - lock (_lock) - { - foreach (var kvp in subscriptions) - { - try - { - SubscribeInternal(kvp.Key); - } - catch (Exception ex) - { - Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key); - } - } - } - }); - } - - // ── Internal COM calls ────────── - - /// - /// Registers a tag subscription with MxAccess COM API (AddItem + AdviseSupervisory). - /// Must be called while holding _lock. - /// - private void SubscribeInternal(string address) - { - if (_lmxProxy == null || _connectionHandle <= 0) - throw new InvalidOperationException("Not connected to MxAccess"); - - // If already subscribed to this address, skip - if (_addressToHandle.ContainsKey(address)) - { - Log.Debug("Already subscribed to {Address}, skipping", address); - return; - } - - // Add the item to MxAccess - int itemHandle = _lmxProxy.AddItem(_connectionHandle, address); - - // Track handle-to-address and address-to-handle mappings - _handleToAddress[itemHandle] = address; - _addressToHandle[address] = itemHandle; - - // Advise (subscribe) for data change events - _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle); - - Log.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle); - } - - /// - /// Unregisters a tag subscription from MxAccess COM API (UnAdvise + RemoveItem). - /// Must be called while holding _lock. - /// - private void UnsubscribeInternal(string address) - { - // Never unsubscribe the probe tag — it's a permanent connection health monitor - if (_probeTestTagAddress != null && - string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase)) - { - Log.Debug("Skipping unsubscribe for probe tag {Address}", address); - return; - } - - if (!_addressToHandle.TryGetValue(address, out int itemHandle)) - { - Log.Debug("No active subscription for {Address}, skipping unsubscribe", address); - return; - } - - try - { - if (_lmxProxy != null && _connectionHandle > 0) - { - _lmxProxy.UnAdvise(_connectionHandle, itemHandle); - _lmxProxy.RemoveItem(_connectionHandle, itemHandle); - } - } - catch (Exception ex) - { - Log.Warning(ex, "Error unsubscribing from {Address} (handle {Handle})", address, itemHandle); - } - finally - { - _handleToAddress.Remove(itemHandle); - _addressToHandle.Remove(address); - } - - Log.Debug("Unsubscribed from {Address} (handle {Handle})", address, itemHandle); - } - - /// - /// Disposable subscription handle that unsubscribes on disposal. - /// - private sealed class SubscriptionHandle : IAsyncDisposable - { - private readonly MxAccessClient _client; - private readonly List _addresses; - private readonly Action _callback; - private bool _disposed; - - public SubscriptionHandle(MxAccessClient client, List addresses, Action callback) - { - _client = client; - _addresses = addresses; - _callback = callback; - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - await _client.UnsubscribeAsync(_addresses); - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs deleted file mode 100644 index 1729747..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/MxAccessClient.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ArchestrA.MxAccess; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.MxAccess -{ - /// - /// Wraps the ArchestrA MXAccess COM API. All COM operations - /// execute on a dedicated STA thread with a Windows message pump - /// so that COM callbacks (OnDataChange, OnWriteComplete) are - /// delivered correctly. - /// - public sealed partial class MxAccessClient : IScadaClient - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly object _lock = new object(); - private readonly int _maxConcurrentOperations; - private readonly int _readTimeoutMs; - private readonly int _writeTimeoutMs; - private readonly int _monitorIntervalMs; - private readonly bool _autoReconnect; - private readonly string? _nodeName; - private readonly string? _galaxyName; - private readonly string _clientName; - - private readonly SemaphoreSlim _readSemaphore; - private readonly SemaphoreSlim _writeSemaphore; - - // STA thread for COM interop - private readonly StaComThread _staThread; - - // COM objects — only accessed on the STA thread - private LMXProxyServer? _lmxProxy; - private int _connectionHandle; - - // State - private ConnectionState _connectionState = ConnectionState.Disconnected; - private DateTime _connectedSince; - private bool _disposed; - - // Reconnect - private CancellationTokenSource? _reconnectCts; - - // Probe configuration - private readonly string? _probeTestTagAddress; - private readonly int _probeStaleThresholdMs; - - // Probe state — updated by OnDataChange callback, read by monitor loop - private DateTime _lastProbeValueTime; - - // Reconnect counter - private int _reconnectCount; - - // Stored subscriptions for reconnect replay - private readonly Dictionary> _storedSubscriptions - = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - // Handle-to-address mapping for resolving COM callbacks - private readonly Dictionary _handleToAddress = new Dictionary(); - - // Address-to-handle mapping for unsubscribe by address - private readonly Dictionary _addressToHandle - = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Pending write operations tracked by item handle - private readonly Dictionary> _pendingWrites - = new Dictionary>(); - - public MxAccessClient( - int maxConcurrentOperations = 10, - int readTimeoutSeconds = 5, - int writeTimeoutSeconds = 5, - int monitorIntervalSeconds = 5, - bool autoReconnect = true, - string? nodeName = null, - string? galaxyName = null, - string? probeTestTagAddress = null, - int probeStaleThresholdMs = 5000, - string? clientName = null) - { - _maxConcurrentOperations = maxConcurrentOperations; - _readTimeoutMs = readTimeoutSeconds * 1000; - _writeTimeoutMs = writeTimeoutSeconds * 1000; - _monitorIntervalMs = monitorIntervalSeconds * 1000; - _autoReconnect = autoReconnect; - _nodeName = nodeName; - _galaxyName = galaxyName; - _probeTestTagAddress = probeTestTagAddress; - _probeStaleThresholdMs = probeStaleThresholdMs; - _clientName = clientName ?? "LmxProxy-" + Guid.NewGuid().ToString("N").Substring(0, 8); - - _readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); - _writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); - - _staThread = new StaComThread(); - _staThread.Start(); - } - - public bool IsConnected - { - get - { - lock (_lock) - { - return _lmxProxy != null - && _connectionState == ConnectionState.Connected - && _connectionHandle > 0; - } - } - } - - public ConnectionState ConnectionState - { - get { lock (_lock) { return _connectionState; } } - } - - public event EventHandler? ConnectionStateChanged; - - private void SetState(ConnectionState newState, string? message = null) - { - ConnectionState previousState; - lock (_lock) - { - previousState = _connectionState; - _connectionState = newState; - } - - if (previousState != newState) - { - Log.Information("Connection state changed: {Previous} -> {Current} {Message}", - previousState, newState, message ?? ""); - ConnectionStateChanged?.Invoke(this, - new ConnectionStateChangedEventArgs(previousState, newState, message)); - } - } - - public async ValueTask DisposeAsync() - { - if (_disposed) return; - _disposed = true; - - _reconnectCts?.Cancel(); - - try - { - await DisconnectAsync(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error during disposal disconnect"); - } - - _readSemaphore.Dispose(); - _writeSemaphore.Dispose(); - _reconnectCts?.Dispose(); - _staThread.Dispose(); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs deleted file mode 100644 index 037b0b6..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/MxAccess/StaComThread.cs +++ /dev/null @@ -1,247 +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.LmxProxy.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 - /// so that COM callbacks (OnDataChange, OnWriteComplete) are delivered - /// via the message loop. - /// - 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 Thread _thread; - private readonly TaskCompletionSource _ready = new TaskCompletionSource(); - private readonly ConcurrentQueue _workItems = new ConcurrentQueue(); - private volatile uint _nativeThreadId; - private bool _disposed; - - private long _totalMessages; - private long _appMessages; - private long _dispatchedMessages; - private long _workItemsExecuted; - private DateTime _lastLogTime; - - public StaComThread() - { - _thread = new Thread(ThreadEntry) - { - Name = "MxAccess-STA", - IsBackground = true - }; - _thread.SetApartmentState(ApartmentState.STA); - } - - /// - /// Starts the STA thread and waits until the message pump is running. - /// - public void Start() - { - _thread.Start(); - _ready.Task.GetAwaiter().GetResult(); - Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId); - } - - /// - /// Marshals a synchronous action onto the STA thread and returns a Task - /// that completes when the action finishes. - /// - public Task RunAsync(Action action) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaComThread)); - - var tcs = new TaskCompletionSource(); - _workItems.Enqueue(() => - { - try - { - action(); - tcs.TrySetResult(true); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero); - return tcs.Task; - } - - /// - /// Marshals a synchronous function onto the STA thread and returns - /// a Task<T> with the result. - /// - public Task RunAsync(Func func) - { - if (_disposed) throw new ObjectDisposedException(nameof(StaComThread)); - - var tcs = new TaskCompletionSource(); - _workItems.Enqueue(() => - { - try - { - tcs.TrySetResult(func()); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }); - PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero); - return tcs.Task; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - if (_nativeThreadId != 0) - 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"); - } - - Log.Information("STA COM thread stopped"); - } - - private void ThreadEntry() - { - try - { - _nativeThreadId = GetCurrentThreadId(); - - // Force message queue creation by peeking - MSG msg; - PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE); - - _ready.TrySetResult(true); - _lastLogTime = DateTime.UtcNow; - - Log.Debug("STA message pump entering loop"); - - // Run the message loop — blocks until WM_QUIT - while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0) - { - _totalMessages++; - - if (msg.message == WM_APP) - { - _appMessages++; - DrainQueue(); - } - else if (msg.message == WM_APP + 1) - { - // Shutdown signal — drain remaining work then quit - DrainQueue(); - PostQuitMessage(0); - } - else - { - _dispatchedMessages++; - TranslateMessage(ref msg); - DispatchMessage(ref msg); - } - - LogPumpStatsIfDue(); - } - - Log.Information("STA message pump exited loop (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); - } - } - - private void DrainQueue() - { - while (_workItems.TryDequeue(out var workItem)) - { - _workItemsExecuted++; - try - { - workItem(); - } - catch (Exception ex) - { - Log.Error(ex, "Unhandled exception in STA work item"); - } - } - } - - 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; - } - - #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/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs deleted file mode 100644 index bcbe790..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using Microsoft.Extensions.Configuration; -using Serilog; -using Topshelf; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host -{ - internal static class Program - { - static int Main(string[] args) - { - // 1. Build configuration (instance override file loaded from LMXPROXY_INSTANCE env var) - var instance = Environment.GetEnvironmentVariable("LMXPROXY_INSTANCE"); - var configuration = new ConfigurationBuilder() - .SetBasePath(AppDomain.CurrentDomain.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) - .AddJsonFile($"appsettings.{instance}.json", optional: true, reloadOnChange: false) - .AddEnvironmentVariables() - .Build(); - - // 2. Set working directory to exe location so relative log paths resolve correctly - Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory; - - // 3. Configure Serilog - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.FromLogContext() - .Enrich.WithMachineName() - .Enrich.WithThreadId() - .CreateLogger(); - - try - { - // 4. Bind configuration - var config = new LmxProxyConfiguration(); - configuration.Bind(config); - - // 5. Configure Topshelf - var exitCode = HostFactory.Run(host => - { - host.UseSerilog(); - - host.Service(service => - { - service.ConstructUsing(() => new LmxProxyService(config)); - service.WhenStarted(s => s.Start()); - service.WhenStopped(s => s.Stop()); - service.WhenPaused(s => s.Pause()); - service.WhenContinued(s => s.Continue()); - service.WhenShutdown(s => s.Stop()); - }); - - host.SetServiceName("ZB.MOM.WW.LmxProxy.Host"); - host.SetDisplayName("SCADA Bridge LMX Proxy"); - host.SetDescription("gRPC proxy for AVEVA System Platform via MXAccess COM API"); - - host.StartAutomatically(); - host.EnablePauseAndContinue(); - - host.EnableServiceRecovery(recovery => - { - recovery.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes); - recovery.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes); - recovery.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes); - recovery.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays); - }); - }); - - return (int)exitCode; - } - catch (Exception ex) - { - Log.Fatal(ex, "LmxProxy service terminated unexpectedly"); - return 1; - } - finally - { - Log.CloseAndFlush(); - } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs deleted file mode 100644 index ba7c779..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKey.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// An API key with description, role, and enabled state. - public class ApiKey - { - public string Key { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly; - public bool Enabled { get; set; } = true; - } - - /// API key role for authorization. - public enum ApiKeyRole - { - /// Read and subscribe only. - ReadOnly, - /// Full access including writes. - ReadWrite - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs deleted file mode 100644 index 1fc9ae2..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// JSON structure for the API key configuration file. - public class ApiKeyConfiguration - { - public List ApiKeys { get; set; } = new List(); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs deleted file mode 100644 index 2001704..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyInterceptor.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Grpc.Core; -using Grpc.Core.Interceptors; -using GrpcStatus = Grpc.Core.Status; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// gRPC server interceptor that enforces API key authentication and role-based authorization. - /// Extracts x-api-key from metadata, validates via ApiKeyService, enforces ReadWrite for writes. - /// - public class ApiKeyInterceptor : Interceptor - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly ApiKeyService _apiKeyService; - - /// RPC method names that require the ReadWrite role. - private static readonly HashSet WriteProtectedMethods = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - - public ApiKeyInterceptor(ApiKeyService apiKeyService) - { - _apiKeyService = apiKeyService; - } - - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - ValidateApiKey(context); - return await continuation(request, context); - } - - public override async Task ServerStreamingServerHandler( - TRequest request, - IServerStreamWriter responseStream, - ServerCallContext context, - ServerStreamingServerMethod continuation) - { - ValidateApiKey(context); - await continuation(request, responseStream, context); - } - - private void ValidateApiKey(ServerCallContext context) - { - // Extract x-api-key from metadata - var apiKeyEntry = context.RequestHeaders.Get("x-api-key"); - var apiKey = apiKeyEntry?.Value ?? string.Empty; - - if (string.IsNullOrEmpty(apiKey)) - { - Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method); - throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Missing x-api-key header")); - } - - var key = _apiKeyService.ValidateApiKey(apiKey); - if (key == null) - { - Log.Warning("Request rejected: invalid API key for {Method}", context.Method); - throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid API key")); - } - - // Check write authorization - if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite) - { - Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method); - throw new RpcException(new GrpcStatus(StatusCode.PermissionDenied, - "Write operations require a ReadWrite API key")); - } - - // Store the validated key in UserState for downstream use - context.UserState["ApiKey"] = key; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs deleted file mode 100644 index 2b81084..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/ApiKeyService.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using Newtonsoft.Json; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher. - /// - public sealed class ApiKeyService : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly string _configFilePath; - private readonly FileSystemWatcher? _watcher; - private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1); - private volatile Dictionary _keys = new Dictionary(StringComparer.Ordinal); - private DateTime _lastReloadTime = DateTime.MinValue; - private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1); - - public ApiKeyService(string configFilePath) - { - _configFilePath = Path.GetFullPath(configFilePath); - - // Auto-generate default file if missing - if (!File.Exists(_configFilePath)) - { - GenerateDefaultKeyFile(); - } - - // Initial load - LoadKeys(); - - // Set up FileSystemWatcher for hot-reload - var directory = Path.GetDirectoryName(_configFilePath); - var fileName = Path.GetFileName(_configFilePath); - if (directory != null) - { - _watcher = new FileSystemWatcher(directory, fileName) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size, - EnableRaisingEvents = true - }; - _watcher.Changed += OnFileChanged; - } - } - - /// - /// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise. - /// - public ApiKey? ValidateApiKey(string apiKey) - { - if (string.IsNullOrEmpty(apiKey)) return null; - return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null; - } - - /// - /// Checks if a key has the required role. - /// ReadWrite implies ReadOnly. - /// - public bool HasRole(string apiKey, ApiKeyRole requiredRole) - { - var key = ValidateApiKey(apiKey); - if (key == null) return false; - - switch (requiredRole) - { - case ApiKeyRole.ReadOnly: - return true; // Both roles have ReadOnly - case ApiKeyRole.ReadWrite: - return key.Role == ApiKeyRole.ReadWrite; - default: - return false; - } - } - - /// Gets the count of loaded API keys. - public int KeyCount => _keys.Count; - - private void GenerateDefaultKeyFile() - { - Log.Information("API key file not found at {Path}, generating defaults", _configFilePath); - - var config = new ApiKeyConfiguration - { - ApiKeys = new List - { - new ApiKey - { - Key = GenerateRandomKey(), - Description = "Default ReadOnly key (auto-generated)", - Role = ApiKeyRole.ReadOnly, - Enabled = true - }, - new ApiKey - { - Key = GenerateRandomKey(), - Description = "Default ReadWrite key (auto-generated)", - Role = ApiKeyRole.ReadWrite, - Enabled = true - } - } - }; - - var directory = Path.GetDirectoryName(_configFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); - - var json = JsonConvert.SerializeObject(config, Formatting.Indented); - File.WriteAllText(_configFilePath, json); - Log.Information("Default API key file generated at {Path}", _configFilePath); - } - - private static string GenerateRandomKey() - { - // 32 random bytes -> 64-char hex string - var bytes = new byte[32]; - using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) - { - rng.GetBytes(bytes); - } - return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); - } - - private void LoadKeys() - { - try - { - var json = File.ReadAllText(_configFilePath); - var config = JsonConvert.DeserializeObject(json); - if (config?.ApiKeys != null) - { - _keys = config.ApiKeys - .Where(k => !string.IsNullOrEmpty(k.Key)) - .ToDictionary(k => k.Key, k => k, StringComparer.Ordinal); - Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath); - } - else - { - Log.Warning("API key file at {Path} contained no keys", _configFilePath); - _keys = new Dictionary(StringComparer.Ordinal); - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath); - } - } - - private void OnFileChanged(object sender, FileSystemEventArgs e) - { - // Debounce: ignore rapid changes within 1 second - if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return; - - if (_reloadLock.Wait(0)) - { - try - { - _lastReloadTime = DateTime.UtcNow; - Log.Information("API key file changed, reloading"); - - // Small delay to let the file system finish writing - Thread.Sleep(100); - LoadKeys(); - } - finally - { - _reloadLock.Release(); - } - } - } - - public void Dispose() - { - _watcher?.Dispose(); - _reloadLock.Dispose(); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs deleted file mode 100644 index 26beaba..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Security/TlsCertificateManager.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.IO; -using Grpc.Core; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Security -{ - /// - /// Manages TLS certificates for the gRPC server. - /// If TLS is enabled but certs are missing, logs a warning (self-signed generation - /// would be added as a future enhancement, or done manually). - /// - public static class TlsCertificateManager - { - private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager)); - - /// - /// Creates gRPC server credentials based on TLS configuration. - /// Returns InsecureServerCredentials if TLS is disabled. - /// - public static ServerCredentials CreateServerCredentials(TlsConfiguration config) - { - if (!config.Enabled) - { - Log.Information("TLS disabled, using insecure server credentials"); - return ServerCredentials.Insecure; - } - - if (!File.Exists(config.ServerCertificatePath) || !File.Exists(config.ServerKeyPath)) - { - Log.Warning("TLS enabled but certificate files not found. Falling back to insecure credentials. " + - "Cert: {CertPath}, Key: {KeyPath}", - config.ServerCertificatePath, config.ServerKeyPath); - return ServerCredentials.Insecure; - } - - var certChain = File.ReadAllText(config.ServerCertificatePath); - var privateKey = File.ReadAllText(config.ServerKeyPath); - - var keyCertPair = new KeyCertificatePair(certChain, privateKey); - - if (config.RequireClientCertificate && File.Exists(config.ClientCaCertificatePath)) - { - var caCert = File.ReadAllText(config.ClientCaCertificatePath); - Log.Information("TLS enabled with mutual TLS (client certificate required)"); - return new SslServerCredentials( - new[] { keyCertPair }, - caCert, - SslClientCertificateRequestType.RequestAndRequireAndVerify); - } - - Log.Information("TLS enabled (server-only)"); - return new SslServerCredentials(new[] { keyCertPair }); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs deleted file mode 100644 index 4e80b7c..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Serilog; - -namespace ZB.MOM.WW.LmxProxy.Host.Sessions -{ - /// - /// Tracks active client sessions in memory. - /// Thread-safe via ConcurrentDictionary. - /// - public sealed class SessionManager : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly ConcurrentDictionary _sessions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly Timer? _scavengingTimer; - private readonly TimeSpan _inactivityTimeout; - private Action? _onSessionScavenged; - - /// - /// Creates a SessionManager with optional inactivity scavenging. - /// - /// - /// Sessions inactive for this many minutes are automatically terminated. - /// Set to 0 to disable scavenging. - /// - public SessionManager(int inactivityTimeoutMinutes = 5) - { - _inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes); - - if (inactivityTimeoutMinutes > 0) - { - // Check every 60 seconds - _scavengingTimer = new Timer(ScavengeInactiveSessions, null, - TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - } - - /// - /// Register a callback invoked when a session is scavenged due to inactivity. - /// The callback receives the session ID. - /// - public void OnSessionScavenged(Action callback) - { - _onSessionScavenged = callback; - } - - /// Gets the count of active sessions. - public int ActiveSessionCount => _sessions.Count; - - /// - /// Creates a new session. - /// Returns the 32-character hex GUID session ID. - /// - public string CreateSession(string clientId, string apiKey) - { - var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens - var sessionInfo = new SessionInfo(sessionId, clientId, apiKey); - _sessions[sessionId] = sessionInfo; - - Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId); - return sessionId; - } - - /// - /// Validates a session ID. Updates LastActivity on success. - /// Returns true if the session exists. - /// - public bool ValidateSession(string sessionId) - { - if (_sessions.TryGetValue(sessionId, out var session)) - { - session.TouchLastActivity(); - return true; - } - return false; - } - - /// - /// Terminates a session. Returns true if the session existed. - /// - public bool TerminateSession(string sessionId) - { - if (_sessions.TryRemove(sessionId, out _)) - { - Log.Information("Session terminated: {SessionId}", sessionId); - return true; - } - return false; - } - - /// Gets session info by ID, or null if not found. - public SessionInfo? GetSession(string sessionId) - { - _sessions.TryGetValue(sessionId, out var session); - return session; - } - - /// Gets a snapshot of all active sessions. - public IReadOnlyList GetAllSessions() - { - return _sessions.Values.ToList().AsReadOnly(); - } - - /// - /// Scavenges sessions that have been inactive for longer than the timeout. - /// - private void ScavengeInactiveSessions(object? state) - { - if (_inactivityTimeout <= TimeSpan.Zero) return; - - var cutoff = DateTime.UtcNow - _inactivityTimeout; - var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList(); - - foreach (var kvp in expired) - { - if (_sessions.TryRemove(kvp.Key, out _)) - { - Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})", - kvp.Key, kvp.Value.LastActivity); - - try - { - _onSessionScavenged?.Invoke(kvp.Key); - } - catch (Exception ex) - { - Log.Warning(ex, "Error in session scavenge callback for {SessionId}", kvp.Key); - } - } - } - } - - public void Dispose() - { - _scavengingTimer?.Dispose(); - _sessions.Clear(); - } - } - - /// - /// Information about an active client session. - /// - public class SessionInfo - { - public SessionInfo(string sessionId, string clientId, string apiKey) - { - SessionId = sessionId; - ClientId = clientId; - ApiKey = apiKey; - ConnectedAt = DateTime.UtcNow; - LastActivity = DateTime.UtcNow; - } - - public string SessionId { get; } - public string ClientId { get; } - public string ApiKey { get; } - public DateTime ConnectedAt { get; } - public DateTime LastActivity { get; private set; } - public long ConnectedSinceUtcTicks => ConnectedAt.Ticks; - - /// Updates the last activity timestamp to now. - public void TouchLastActivity() - { - LastActivity = DateTime.UtcNow; - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs deleted file mode 100644 index 142bc6f..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusModels.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace ZB.MOM.WW.LmxProxy.Host.Status -{ - public class StatusData - { - public DateTime Timestamp { get; set; } - public string ServiceName { get; set; } = ""; - public string Version { get; set; } = ""; - public ConnectionStatus Connection { get; set; } = new ConnectionStatus(); - public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus(); - public PerformanceStatus Performance { get; set; } = new PerformanceStatus(); - public HealthInfo Health { get; set; } = new HealthInfo(); - } - - public class ConnectionStatus - { - public bool IsConnected { get; set; } - public string State { get; set; } = ""; - public string NodeName { get; set; } = ""; - public string GalaxyName { get; set; } = ""; - public DateTime? ConnectedSince { get; set; } - public int ReconnectCount { get; set; } - } - - public class SubscriptionStatus - { - public int TotalClients { get; set; } - public int TotalTags { get; set; } - public int ActiveSubscriptions { get; set; } - public long TotalDelivered { get; set; } - public long TotalDropped { get; set; } - } - - public class PerformanceStatus - { - public long TotalOperations { get; set; } - public double AverageSuccessRate { get; set; } - public Dictionary Operations { get; set; } - = new Dictionary(); - } - - public class OperationStatus - { - public long TotalCount { get; set; } - public double SuccessRate { get; set; } - public double AverageMilliseconds { get; set; } - public double MinMilliseconds { get; set; } - public double MaxMilliseconds { get; set; } - public double Percentile95Milliseconds { get; set; } - } - - public class HealthInfo - { - public string Status { get; set; } = ""; - public string Description { get; set; } = ""; - public Dictionary Data { get; set; } = new Dictionary(); - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs deleted file mode 100644 index e8116ae..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Status -{ - /// - /// Aggregates health, metrics, and subscription data into status reports. - /// - public class StatusReportService - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly SubscriptionManager _subscriptionManager; - private readonly PerformanceMetrics _performanceMetrics; - private readonly HealthCheckService _healthCheckService; - - public StatusReportService( - IScadaClient scadaClient, - SubscriptionManager subscriptionManager, - PerformanceMetrics performanceMetrics, - HealthCheckService healthCheckService) - { - _scadaClient = scadaClient; - _subscriptionManager = subscriptionManager; - _performanceMetrics = performanceMetrics; - _healthCheckService = healthCheckService; - } - - public async Task GenerateHtmlReportAsync() - { - try - { - var statusData = await CollectStatusDataAsync(); - return GenerateHtmlFromStatusData(statusData); - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to generate HTML report"); - return GenerateErrorHtml(ex); - } - } - - public async Task GenerateJsonReportAsync() - { - var statusData = await CollectStatusDataAsync(); - var settings = new JsonSerializerSettings - { - Formatting = Formatting.Indented, - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; - return JsonConvert.SerializeObject(statusData, settings); - } - - public async Task IsHealthyAsync() - { - var result = await _healthCheckService.CheckHealthAsync(new HealthCheckContext()); - return result.Status == HealthStatus.Healthy; - } - - private async Task CollectStatusDataAsync() - { - var statusData = new StatusData - { - Timestamp = DateTime.UtcNow, - ServiceName = "ZB.MOM.WW.LmxProxy.Host", - Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0" - }; - - // Connection info - statusData.Connection = new ConnectionStatus - { - IsConnected = _scadaClient.IsConnected, - State = _scadaClient.ConnectionState.ToString(), - ConnectedSince = _scadaClient.IsConnected ? _scadaClient.ConnectedSince : (DateTime?)null, - ReconnectCount = _scadaClient.ReconnectCount - }; - - // Subscription stats - var subStats = _subscriptionManager.GetStats(); - statusData.Subscriptions = new SubscriptionStatus - { - TotalClients = subStats.TotalClients, - TotalTags = subStats.TotalTags, - ActiveSubscriptions = subStats.ActiveSubscriptions, - TotalDelivered = subStats.TotalDelivered, - TotalDropped = subStats.TotalDropped - }; - - // Performance stats - var allStats = _performanceMetrics.GetStatistics(); - long totalOps = 0; - double totalSuccessRate = 0; - int opCount = 0; - - foreach (var kvp in allStats) - { - totalOps += kvp.Value.TotalCount; - totalSuccessRate += kvp.Value.SuccessRate; - opCount++; - - statusData.Performance.Operations[kvp.Key] = new OperationStatus - { - TotalCount = kvp.Value.TotalCount, - SuccessRate = kvp.Value.SuccessRate, - AverageMilliseconds = kvp.Value.AverageMilliseconds, - MinMilliseconds = kvp.Value.MinMilliseconds, - MaxMilliseconds = kvp.Value.MaxMilliseconds, - Percentile95Milliseconds = kvp.Value.Percentile95Milliseconds - }; - } - - statusData.Performance.TotalOperations = totalOps; - statusData.Performance.AverageSuccessRate = opCount > 0 - ? totalSuccessRate / opCount - : 1.0; - - // Health check - var healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext()); - statusData.Health = new HealthInfo - { - Status = healthResult.Status.ToString(), - Description = healthResult.Description ?? "" - }; - if (healthResult.Data != null) - { - foreach (var kvp in healthResult.Data) - { - statusData.Health.Data[kvp.Key] = kvp.Value?.ToString() ?? ""; - } - } - - return statusData; - } - - private static string GenerateHtmlFromStatusData(StatusData statusData) - { - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(" LmxProxy Status"); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("

LmxProxy Status Dashboard

"); - - // Connection card - var connClass = statusData.Connection.IsConnected ? "card-green" : "card-red"; - sb.AppendLine($"
"); - sb.AppendLine($"
"); - sb.AppendLine("

Connection

"); - sb.AppendLine($"

Connected: {statusData.Connection.IsConnected}

"); - sb.AppendLine($"

State: {statusData.Connection.State}

"); - if (statusData.Connection.ConnectedSince.HasValue) - sb.AppendLine($"

Connected Since: {statusData.Connection.ConnectedSince.Value:yyyy-MM-dd HH:mm:ss} UTC

"); - if (statusData.Connection.ReconnectCount > 0) - sb.AppendLine($"

Reconnects: {statusData.Connection.ReconnectCount}

"); - if (!string.IsNullOrEmpty(statusData.Connection.NodeName)) - sb.AppendLine($"

Node: {statusData.Connection.NodeName}

"); - if (!string.IsNullOrEmpty(statusData.Connection.GalaxyName)) - sb.AppendLine($"

Galaxy: {statusData.Connection.GalaxyName}

"); - sb.AppendLine("
"); - - // Health card - var healthClass = GetHealthCardClass(statusData.Health.Status); - var healthCss = GetHealthStatusCss(statusData.Health.Status); - sb.AppendLine($"
"); - sb.AppendLine("

Health

"); - sb.AppendLine($"

{statusData.Health.Status}

"); - sb.AppendLine($"

{statusData.Health.Description}

"); - sb.AppendLine("
"); - - // Subscriptions card - var subCardCss = statusData.Subscriptions.TotalDropped > 0 ? "card-yellow" : "card-green"; - sb.AppendLine($"
"); - sb.AppendLine("

Subscriptions

"); - sb.AppendLine($"

Clients: {statusData.Subscriptions.TotalClients}

"); - sb.AppendLine($"

Tags: {statusData.Subscriptions.TotalTags}

"); - sb.AppendLine($"

Active: {statusData.Subscriptions.ActiveSubscriptions}

"); - sb.AppendLine($"

Delivered: {statusData.Subscriptions.TotalDelivered:N0}

"); - if (statusData.Subscriptions.TotalDropped > 0) - { - sb.AppendLine($"

Dropped: {statusData.Subscriptions.TotalDropped:N0}

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

RPC Operations

"); - sb.AppendLine(" "); - sb.AppendLine(" "); - - // All known RPC operations — show each even if 0 calls - var rpcNames = new[] { "Read", "ReadBatch", "Write", "WriteBatch", "Subscribe" }; - foreach (var rpcName in rpcNames) - { - var key = rpcName.Substring(0, 1).ToLowerInvariant() + rpcName.Substring(1); - if (statusData.Performance.Operations.TryGetValue(key, out var op)) - { - sb.AppendLine($" " + - $"" + - $"" + - $"" + - $"" + - $"" + - $"" + - $"" + - $""); - } - else - { - sb.AppendLine($" "); - } - } - - sb.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
{rpcName}{op.TotalCount}{op.SuccessRate:P1}{op.AverageMilliseconds:F1}{op.MinMilliseconds:F1}{op.MaxMilliseconds:F1}{op.Percentile95Milliseconds:F1}
{rpcName}0
"); - sb.AppendLine("
"); - - sb.AppendLine($"
Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC | Service: {statusData.ServiceName} v{statusData.Version}
"); - sb.AppendLine(""); - sb.AppendLine(""); - return sb.ToString(); - } - - private static string GetHealthCardClass(string status) - { - switch (status) - { - case "Healthy": return "card-green"; - case "Degraded": return "card-yellow"; - default: return "card-red"; - } - } - - private static string GetHealthStatusCss(string status) - { - switch (status) - { - case "Healthy": return "status-healthy"; - case "Degraded": return "status-degraded"; - default: return "status-unhealthy"; - } - } - - private static string GenerateErrorHtml(Exception ex) - { - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine("LmxProxy Status - Error"); - sb.AppendLine(""); - sb.AppendLine("

Error generating status report

"); - sb.AppendLine($"

{ex.Message}

"); - sb.AppendLine(""); - return sb.ToString(); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs deleted file mode 100644 index a3b44f7..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Status -{ - /// - /// HTTP status server providing an HTML dashboard, JSON API, and health endpoint. - /// - public class StatusWebServer : IDisposable - { - private static readonly ILogger Logger = Log.ForContext(); - - private readonly WebServerConfiguration _configuration; - private readonly StatusReportService _statusReportService; - private HttpListener? _httpListener; - private CancellationTokenSource? _cancellationTokenSource; - private Task? _listenerTask; - private bool _disposed; - - public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) - { - _configuration = configuration; - _statusReportService = statusReportService; - } - - public bool Start() - { - if (!_configuration.Enabled) - { - Logger.Information("Status web server is disabled"); - return true; - } - - try - { - _httpListener = new HttpListener(); - var prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/"; - if (!prefix.EndsWith("/")) - prefix += "/"; - - _httpListener.Prefixes.Add(prefix); - _httpListener.Start(); - - _cancellationTokenSource = new CancellationTokenSource(); - _listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token)); - - Logger.Information("Status web server started on {Prefix}", prefix); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Failed to start status web server"); - return false; - } - } - - public bool Stop() - { - if (!_configuration.Enabled || _httpListener == null) - return true; - - try - { - _cancellationTokenSource?.Cancel(); - - if (_listenerTask != null) - { - _listenerTask.Wait(TimeSpan.FromSeconds(5)); - } - - _httpListener.Stop(); - _httpListener.Close(); - - Logger.Information("Status web server stopped"); - return true; - } - catch (Exception ex) - { - Logger.Error(ex, "Error stopping status web server"); - return false; - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - Stop(); - _cancellationTokenSource?.Dispose(); - if (_httpListener != null) - { - ((IDisposable)_httpListener).Dispose(); - } - } - - private async Task HandleRequestsAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening) - { - try - { - var context = await _httpListener.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(context)); - } - catch (ObjectDisposedException) - { - // Expected during shutdown - break; - } - catch (HttpListenerException ex) when (ex.ErrorCode == 995) - { - // ERROR_OPERATION_ABORTED — expected during shutdown - break; - } - catch (Exception ex) - { - Logger.Error(ex, "Error accepting HTTP request"); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - } - } - } - - private async Task HandleRequestAsync(HttpListenerContext context) - { - try - { - if (context.Request.HttpMethod != "GET") - { - context.Response.StatusCode = 405; - await WriteResponseAsync(context.Response, "Method Not Allowed", "text/plain"); - return; - } - - var path = context.Request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/"; - - switch (path) - { - case "/": - await HandleStatusPageAsync(context.Response); - break; - case "/api/status": - await HandleStatusApiAsync(context.Response); - break; - case "/api/health": - await HandleHealthApiAsync(context.Response); - break; - default: - context.Response.StatusCode = 404; - await WriteResponseAsync(context.Response, "Not Found", "text/plain"); - break; - } - } - catch (Exception ex) - { - Logger.Error(ex, "Error handling HTTP request"); - try - { - context.Response.StatusCode = 500; - await WriteResponseAsync(context.Response, "Internal Server Error", "text/plain"); - } - catch - { - // Ignore errors writing error response - } - } - } - - private async Task HandleStatusPageAsync(HttpListenerResponse response) - { - var html = await _statusReportService.GenerateHtmlReportAsync(); - await WriteResponseAsync(response, html, "text/html; charset=utf-8"); - } - - private async Task HandleStatusApiAsync(HttpListenerResponse response) - { - var json = await _statusReportService.GenerateJsonReportAsync(); - await WriteResponseAsync(response, json, "application/json; charset=utf-8"); - } - - private async Task HandleHealthApiAsync(HttpListenerResponse response) - { - var isHealthy = await _statusReportService.IsHealthyAsync(); - if (isHealthy) - { - response.StatusCode = 200; - await WriteResponseAsync(response, "OK", "text/plain"); - } - else - { - response.StatusCode = 503; - await WriteResponseAsync(response, "UNHEALTHY", "text/plain"); - } - } - - private static async Task WriteResponseAsync( - HttpListenerResponse response, string content, string contentType) - { - response.ContentType = contentType; - response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); - response.Headers.Add("Pragma", "no-cache"); - response.Headers.Add("Expires", "0"); - - var buffer = Encoding.UTF8.GetBytes(content); - response.ContentLength64 = buffer.Length; - await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); - response.OutputStream.Close(); - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs deleted file mode 100644 index 24c859e..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Subscriptions/SubscriptionManager.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using Serilog; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions -{ - /// - /// Manages per-client subscription channels with shared MxAccess subscriptions. - /// Ref-counted tag subscriptions: first client creates, last client disposes. - /// - public sealed class SubscriptionManager : IDisposable - { - private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly IScadaClient _scadaClient; - private readonly int _channelCapacity; - private readonly BoundedChannelFullMode _channelFullMode; - - // Subscription ID -> ClientSubscription - private readonly ConcurrentDictionary _clientSubscriptions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - // Tag address -> TagSubscription (shared, ref-counted) - private readonly ConcurrentDictionary _tagSubscriptions - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - // Session ID -> set of subscription IDs owned by that session - private readonly ConcurrentDictionary> _sessionSubscriptions - = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - - private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim(); - - public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000, - BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest) - { - _scadaClient = scadaClient; - _channelCapacity = channelCapacity; - _channelFullMode = channelFullMode; - } - - /// - /// Creates a subscription for a session. Returns a ChannelReader and unique - /// subscription ID. Multiple subscriptions per session are supported. - /// Awaits COM subscription creation so the initial OnDataChange callback - /// is not missed. - /// - public async Task<(ChannelReader<(string address, Vtq vtq)> Reader, string SubscriptionId)> SubscribeAsync( - string sessionId, IEnumerable addresses, CancellationToken ct) - { - var subscriptionId = Guid.NewGuid().ToString("N"); - var channel = Channel.CreateBounded<(string address, Vtq vtq)>( - new BoundedChannelOptions(_channelCapacity) - { - FullMode = _channelFullMode, - SingleReader = true, - SingleWriter = false - }); - - var addressSet = new HashSet(addresses, StringComparer.OrdinalIgnoreCase); - - var clientSub = new ClientSubscription(subscriptionId, sessionId, channel, addressSet); - _clientSubscriptions[subscriptionId] = clientSub; - - // Track which session owns this subscription - _sessionSubscriptions.AddOrUpdate( - sessionId, - _ => new HashSet(StringComparer.OrdinalIgnoreCase) { subscriptionId }, - (_, set) => { lock (set) { set.Add(subscriptionId); } return set; }); - - var newTags = new List(); - - _rwLock.EnterWriteLock(); - try - { - foreach (var address in addressSet) - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - tagSub.ClientIds.Add(subscriptionId); - } - else - { - _tagSubscriptions[address] = new TagSubscription(address, - new HashSet(StringComparer.OrdinalIgnoreCase) { subscriptionId }); - newTags.Add(address); - } - } - } - finally - { - _rwLock.ExitWriteLock(); - } - - // Create MxAccess COM subscriptions — awaited so the initial - // OnDataChange (first value delivery after AdviseSupervisory) - // is not lost. The channel and routing are already set up above, - // so any callback that fires during this call will be delivered. - if (newTags.Count > 0) - { - await CreateMxAccessSubscriptionsAsync(newTags); - } - - // Register cancellation cleanup for this subscription only - ct.Register(() => UnsubscribeSubscription(subscriptionId)); - - Log.Information("Session {SessionId} subscription {SubscriptionId} subscribed to {Count} tags ({NewCount} new MxAccess subscriptions)", - sessionId, subscriptionId, addressSet.Count, newTags.Count); - return (channel.Reader, subscriptionId); - } - - private async Task CreateMxAccessSubscriptionsAsync(List addresses) - { - try - { - await _scadaClient.SubscribeAsync( - addresses, - (address, vtq) => OnTagValueChanged(address, vtq)); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to create MxAccess subscriptions for {Count} tags", addresses.Count); - } - } - - /// - /// Called from MxAccessClient's OnDataChange handler. - /// Fans out the update to all subscribed clients. - /// - public void OnTagValueChanged(string address, Vtq vtq) - { - _rwLock.EnterReadLock(); - HashSet? clientIds = null; - try - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - clientIds = new HashSet(tagSub.ClientIds); - } - } - finally - { - _rwLock.ExitReadLock(); - } - - if (clientIds == null || clientIds.Count == 0) return; - - foreach (var clientId in clientIds) - { - if (_clientSubscriptions.TryGetValue(clientId, out var clientSub)) - { - if (!clientSub.Channel.Writer.TryWrite((address, vtq))) - { - clientSub.IncrementDropped(); - Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)", - clientId, address); - } - else - { - clientSub.IncrementDelivered(); - } - } - } - } - - /// - /// Removes a single subscription and cleans up its tag refs. - /// Called when an individual Subscribe stream ends. - /// - public void UnsubscribeSubscription(string subscriptionId) - { - if (!_clientSubscriptions.TryRemove(subscriptionId, out var clientSub)) - return; - - // Remove from session tracking - if (_sessionSubscriptions.TryGetValue(clientSub.SessionId, out var subIds)) - { - lock (subIds) - { - subIds.Remove(subscriptionId); - if (subIds.Count == 0) - { - _sessionSubscriptions.TryRemove(clientSub.SessionId, out _); - } - } - } - - var tagsToDispose = new List(); - - _rwLock.EnterWriteLock(); - try - { - foreach (var address in clientSub.Addresses) - { - if (_tagSubscriptions.TryGetValue(address, out var tagSub)) - { - tagSub.ClientIds.Remove(subscriptionId); - - if (tagSub.ClientIds.Count == 0) - { - _tagSubscriptions.TryRemove(address, out _); - tagsToDispose.Add(address); - } - } - } - } - finally - { - _rwLock.ExitWriteLock(); - } - - if (tagsToDispose.Count > 0) - { - try - { - _scadaClient.UnsubscribeByAddressAsync(tagsToDispose).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - Log.Warning(ex, "Error unsubscribing {Count} tags from MxAccess", tagsToDispose.Count); - } - } - - clientSub.Channel.Writer.TryComplete(); - - Log.Information("Subscription {SubscriptionId} removed ({Delivered} delivered, {Dropped} dropped)", - subscriptionId, clientSub.DeliveredCount, clientSub.DroppedCount); - } - - /// - /// Removes ALL subscriptions for a session. - /// Called on explicit Disconnect or session scavenging. - /// - public void UnsubscribeSession(string sessionId) - { - if (!_sessionSubscriptions.TryRemove(sessionId, out var subscriptionIds)) - return; - - List ids; - lock (subscriptionIds) - { - ids = subscriptionIds.ToList(); - } - - foreach (var subId in ids) - { - UnsubscribeSubscription(subId); - } - - Log.Information("All subscriptions for session {SessionId} removed ({Count} subscriptions)", - sessionId, ids.Count); - } - - /// - /// Sends a bad-quality notification to all subscribed clients for all their tags. - /// Called when MxAccess disconnects. - /// - public void NotifyDisconnection() - { - var badVtq = Vtq.New(null, Quality.Bad_NotConnected); - - foreach (var kvp in _clientSubscriptions) - { - foreach (var address in kvp.Value.Addresses) - { - kvp.Value.Channel.Writer.TryWrite((address, badVtq)); - } - } - } - - /// - /// Logs reconnection for observability. Data flow resumes automatically - /// via MxAccessClient.RecreateStoredSubscriptionsAsync callbacks. - /// - public void NotifyReconnection() - { - Log.Information("MxAccess reconnected -- subscriptions recreated, " + - "data flow will resume via OnDataChange callbacks " + - "({ClientCount} clients, {TagCount} tags)", - _clientSubscriptions.Count, _tagSubscriptions.Count); - } - - /// Returns subscription statistics. - public SubscriptionStats GetStats() - { - long totalDelivered = 0; - long totalDropped = 0; - foreach (var kvp in _clientSubscriptions) - { - totalDelivered += kvp.Value.DeliveredCount; - totalDropped += kvp.Value.DroppedCount; - } - - return new SubscriptionStats( - _sessionSubscriptions.Count, - _tagSubscriptions.Count, - _clientSubscriptions.Values.Sum(c => c.Addresses.Count), - totalDelivered, - totalDropped); - } - - public void Dispose() - { - foreach (var kvp in _clientSubscriptions) - { - kvp.Value.Channel.Writer.TryComplete(); - } - _clientSubscriptions.Clear(); - _sessionSubscriptions.Clear(); - _tagSubscriptions.Clear(); - _rwLock.Dispose(); - } - - // ── Nested types ───────────────────────────────────────── - - private class ClientSubscription - { - public ClientSubscription(string subscriptionId, string sessionId, - Channel<(string address, Vtq vtq)> channel, - HashSet addresses) - { - SubscriptionId = subscriptionId; - SessionId = sessionId; - Channel = channel; - Addresses = addresses; - } - - public string SubscriptionId { get; } - public string SessionId { get; } - public Channel<(string address, Vtq vtq)> Channel { get; } - public HashSet Addresses { get; } - - // Use backing fields for Interlocked - private long _delivered; - private long _dropped; - - public long DeliveredCount => Interlocked.Read(ref _delivered); - public long DroppedCount => Interlocked.Read(ref _dropped); - - public void IncrementDelivered() => Interlocked.Increment(ref _delivered); - public void IncrementDropped() => Interlocked.Increment(ref _dropped); - } - - private class TagSubscription - { - public TagSubscription(string address, HashSet clientIds) - { - Address = address; - ClientIds = clientIds; - } - - public string Address { get; } - public HashSet ClientIds { get; } - } - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj deleted file mode 100644 index bbf30a3..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj +++ /dev/null @@ -1,64 +0,0 @@ - - - - net48 - Exe - 9.0 - enable - false - ZB.MOM.WW.LmxProxy.Host - ZB.MOM.WW.LmxProxy.Host - x86 - x86 - true - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\lib\ArchestrA.MXAccess.dll - true - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json deleted file mode 100644 index 2a4528c..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "GrpcPort": 50051, - "ApiKeyConfigFile": "apikeys.json", - - "Connection": { - "MonitorIntervalSeconds": 5, - "ConnectionTimeoutSeconds": 30, - "ReadTimeoutSeconds": 5, - "WriteTimeoutSeconds": 5, - "MaxConcurrentOperations": 10, - "AutoReconnect": true, - "NodeName": null, - "GalaxyName": null - }, - - "Subscription": { - "ChannelCapacity": 1000, - "ChannelFullMode": "DropOldest" - }, - - "Tls": { - "Enabled": false, - "ServerCertificatePath": "certs/server.crt", - "ServerKeyPath": "certs/server.key", - "ClientCaCertificatePath": "certs/ca.crt", - "RequireClientCertificate": false, - "CheckCertificateRevocation": false - }, - - "WebServer": { - "Enabled": true, - "Port": 8080 - }, - - "HealthCheck": { - "TestTagAddress": "DevPlatform.Scheduler.ScanTime", - "ProbeStaleThresholdMs": 5000 - }, - - "ServiceRecovery": { - "FirstFailureDelayMinutes": 1, - "SecondFailureDelayMinutes": 5, - "SubsequentFailureDelayMinutes": 10, - "ResetPeriodDays": 1 - }, - - "Serilog": { - "Using": [ - "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Enrichers.Environment", - "Serilog.Enrichers.Thread" - ], - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "System": "Warning", - "Grpc": "Information", - "ZB.MOM.WW.LmxProxy.Host.MxAccess.StaComThread": "Debug" - } - }, - "WriteTo": [ - { - "Name": "Console", - "Args": { - "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/lmxproxy-.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 30, - "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}" - } - } - ], - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ] - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2.json b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2.json deleted file mode 100644 index 91d60e2..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "GrpcPort": 50100, - "WebServer": { - "Port": 8081 - } -} diff --git a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2b.json b/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2b.json deleted file mode 100644 index 99be187..0000000 --- a/deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/appsettings.v2b.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "GrpcPort": 50101, - "WebServer": { - "Port": 8082 - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs deleted file mode 100644 index b5095e3..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class CheckApiKeyTests : IntegrationTestBase -{ - [Fact] - public async Task CheckApiKey_ValidReadWrite_ReturnsValid() - { - var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey); - Assert.True(info.IsValid); - } - - [Fact] - public async Task CheckApiKey_ValidReadOnly_ReturnsValid() - { - var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey); - Assert.True(info.IsValid); - } - - [Fact] - public async Task CheckApiKey_Invalid_ReturnsInvalid() - { - var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345"); - Assert.False(info.IsValid); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs deleted file mode 100644 index e0622e6..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class ConnectionTests : IntegrationTestBase -{ - [Fact] - public async Task ConnectAndDisconnect_Succeeds() - { - // Client is connected in InitializeAsync - Assert.True(await Client!.IsConnectedAsync()); - await Client.DisconnectAsync(); - Assert.False(await Client.IsConnectedAsync()); - } - - [Fact] - public async Task ConnectWithInvalidApiKey_Fails() - { - using var badClient = CreateClient(InvalidApiKey); - var ex = await Assert.ThrowsAsync( - () => badClient.ConnectAsync()); - Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode); - } - - [Fact] - public async Task DoubleConnect_IsIdempotent() - { - await Client!.ConnectAsync(); // Already connected — should be no-op - Assert.True(await Client.IsConnectedAsync()); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs deleted file mode 100644 index d2a23ce..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Xunit; -global using ZB.MOM.WW.LmxProxy.Client.Domain; diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs deleted file mode 100644 index d11ee7b..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.Extensions.Configuration; -using ZB.MOM.WW.LmxProxy.Client; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public abstract class IntegrationTestBase : IAsyncLifetime -{ - protected IConfiguration Configuration { get; } - protected string Host { get; } - protected int Port { get; } - protected string ReadWriteApiKey { get; } - protected string ReadOnlyApiKey { get; } - protected string InvalidApiKey { get; } - protected LmxProxyClient? Client { get; set; } - - protected IntegrationTestBase() - { - Configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.test.json") - .Build(); - - var section = Configuration.GetSection("LmxProxy"); - Host = section["Host"] ?? "10.100.0.48"; - Port = int.Parse(section["Port"] ?? "50052"); - ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured"); - ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured"); - InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key"; - } - - protected LmxProxyClient CreateClient(string? apiKey = null) - { - return new LmxProxyClientBuilder() - .WithHost(Host) - .WithPort(Port) - .WithApiKey(apiKey ?? ReadWriteApiKey) - .WithTimeout(TimeSpan.FromSeconds(10)) - .WithRetryPolicy(2, TimeSpan.FromSeconds(1)) - .WithMetrics() - .Build(); - } - - public virtual async Task InitializeAsync() - { - Client = CreateClient(); - await Client.ConnectAsync(); - } - - public virtual async Task DisposeAsync() - { - if (Client is not null) - { - await Client.DisconnectAsync(); - Client.Dispose(); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs deleted file mode 100644 index 9047e71..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class ReadTests : IntegrationTestBase -{ - [Fact] - public async Task Read_BoolTag_ReturnsBoolValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestBool"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_IntTag_ReturnsIntValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestInt"); - Assert.True(vtq.Value is int or long); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_FloatTag_ReturnsFloatValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestFloat"); - Assert.True(vtq.Value is float or double); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_DoubleTag_ReturnsDoubleValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestDouble"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_StringTag_ReturnsStringValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestString"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - } - - [Fact] - public async Task Read_DateTimeTag_ReturnsDateTimeValue() - { - var vtq = await Client!.ReadAsync("TestChildObject.TestDateTime"); - Assert.IsType(vtq.Value); - Assert.True(vtq.Quality.IsGood()); - Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1)); - } - - [Fact] - public async Task ReadBatch_MultipleTags_ReturnsDictionary() - { - var tags = new[] { "TestChildObject.TestString", "TestChildObject.TestInt" }; - var results = await Client!.ReadBatchAsync(tags); - Assert.Equal(2, results.Count); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs deleted file mode 100644 index ed2d307..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class SubscribeTests : IntegrationTestBase -{ - [Fact] - public async Task Subscribe_ReceivesUpdates() - { - var received = new List<(string Tag, Vtq Vtq)>(); - var receivedEvent = new TaskCompletionSource(); - - var subscription = await Client!.SubscribeAsync( - new[] { "TestChildObject.TestInt" }, - (tag, vtq) => - { - received.Add((tag, vtq)); - if (received.Count >= 3) - receivedEvent.TrySetResult(true); - }, - ex => receivedEvent.TrySetException(ex)); - - // Wait up to 30 seconds for at least 3 updates - var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30))); - subscription.Dispose(); - - Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}"); - - var first = received[0]; - Assert.Equal("TestChildObject.TestInt", first.Tag); - Assert.NotNull(first.Vtq.Value); - Assert.True(first.Vtq.Timestamp > DateTime.MinValue); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs deleted file mode 100644 index 64f6405..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class WriteBatchAndWaitTests : IntegrationTestBase -{ - [Fact] - public async Task WriteBatchAndWait_TypeAwareComparison() - { - var values = new Dictionary - { - ["TestChildObject.TestString"] = new TypedValue { StringValue = "BatchTest" } - }; - - var response = await Client!.WriteBatchAndWaitAsync( - values, - flagTag: "TestChildObject.TestString", - flagValue: new TypedValue { StringValue = "BatchTest" }, - timeoutMs: 5000, - pollIntervalMs: 200); - - Assert.True(response.Success); - Assert.True(response.FlagReached); - Assert.True(response.ElapsedMs < 5000); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs deleted file mode 100644 index 38d34e9..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests; - -public class WriteTests : IntegrationTestBase -{ - [Fact] - public async Task WriteAndReadBack_StringValue() - { - string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}"; - await Client!.WriteAsync("TestChildObject.TestString", - new TypedValue { StringValue = testValue }); - - await Task.Delay(500); // Allow time for write to propagate - var vtq = await Client.ReadAsync("TestChildObject.TestString"); - Assert.Equal(testValue, vtq.Value); - } - - [Fact] - public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied() - { - using var readOnlyClient = CreateClient(ReadOnlyApiKey); - await readOnlyClient.ConnectAsync(); - - var ex = await Assert.ThrowsAsync( - () => readOnlyClient.WriteAsync("TestChildObject.TestString", - new TypedValue { StringValue = "should-fail" })); - Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj deleted file mode 100644 index 634f697..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net10.0 - latest - enable - enable - false - - - - - - - - - - - - - - - - - PreserveNewest - - - - - diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json deleted file mode 100644 index d36157e..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "LmxProxy": { - "Host": "localhost", - "Port": 50100, - "ReadWriteApiKey": "c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4", - "ReadOnlyApiKey": "a77d090d4adcfeaac1a50379ec5f971ff282c998599fd8ccf410090c9f290150", - "InvalidApiKey": "invalid-key-that-does-not-exist" - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/xunit.runner.json b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/xunit.runner.json deleted file mode 100644 index dd80f43..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs deleted file mode 100644 index 1f038f6..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using FluentAssertions; -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class ClientMetricsTests -{ - private static LmxProxyClient.ClientMetrics CreateMetrics() => new(); - - [Fact] - public void IncrementOperationCount_Increments() - { - var metrics = CreateMetrics(); - - metrics.IncrementOperationCount("Read"); - metrics.IncrementOperationCount("Read"); - metrics.IncrementOperationCount("Read"); - - var snapshot = metrics.GetSnapshot(); - snapshot["Read_count"].Should().Be(3L); - } - - [Fact] - public void IncrementErrorCount_Increments() - { - var metrics = CreateMetrics(); - - metrics.IncrementErrorCount("Write"); - metrics.IncrementErrorCount("Write"); - - var snapshot = metrics.GetSnapshot(); - snapshot["Write_errors"].Should().Be(2L); - } - - [Fact] - public void RecordLatency_StoresValues() - { - var metrics = CreateMetrics(); - - metrics.RecordLatency("Read", 10); - metrics.RecordLatency("Read", 20); - metrics.RecordLatency("Read", 30); - - var snapshot = metrics.GetSnapshot(); - snapshot.Should().ContainKey("Read_avg_latency_ms"); - snapshot.Should().ContainKey("Read_p95_latency_ms"); - snapshot.Should().ContainKey("Read_p99_latency_ms"); - - var avg = (double)snapshot["Read_avg_latency_ms"]; - avg.Should().BeApproximately(20.0, 0.1); - } - - [Fact] - public void RollingBuffer_CapsAt1000() - { - var metrics = CreateMetrics(); - - for (int i = 0; i < 1100; i++) - { - metrics.RecordLatency("Read", i); - } - - var snapshot = metrics.GetSnapshot(); - // After 1100 entries, the buffer should have capped at 1000 (oldest removed) - // The earliest remaining value should be 100 (entries 0-99 were evicted) - var p95 = (long)snapshot["Read_p95_latency_ms"]; - // p95 of values 100-1099 should be around 1050 - p95.Should().BeGreaterThan(900); - } - - [Fact] - public void GetSnapshot_IncludesP95AndP99() - { - var metrics = CreateMetrics(); - - // Add 100 values: 1, 2, 3, ..., 100 - for (int i = 1; i <= 100; i++) - { - metrics.RecordLatency("Op", i); - } - - var snapshot = metrics.GetSnapshot(); - - var p95 = (long)snapshot["Op_p95_latency_ms"]; - var p99 = (long)snapshot["Op_p99_latency_ms"]; - - // P95 of 1..100 should be 95 - p95.Should().Be(95); - // P99 of 1..100 should be 99 - p99.Should().Be(99); - } - - [Fact] - public void GetSnapshot_ReturnsEmptyForNoData() - { - var metrics = CreateMetrics(); - - var snapshot = metrics.GetSnapshot(); - - snapshot.Should().BeEmpty(); - } - - [Fact] - public void GetSnapshot_TracksMultipleOperations() - { - var metrics = CreateMetrics(); - - metrics.IncrementOperationCount("Read"); - metrics.IncrementOperationCount("Write"); - metrics.IncrementErrorCount("Read"); - metrics.RecordLatency("Read", 10); - metrics.RecordLatency("Write", 20); - - var snapshot = metrics.GetSnapshot(); - - snapshot["Read_count"].Should().Be(1L); - snapshot["Write_count"].Should().Be(1L); - snapshot["Read_errors"].Should().Be(1L); - snapshot.Should().ContainKey("Read_avg_latency_ms"); - snapshot.Should().ContainKey("Write_avg_latency_ms"); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs deleted file mode 100644 index fb3013d..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs +++ /dev/null @@ -1,270 +0,0 @@ -using System.IO; -using FluentAssertions; -using Google.Protobuf; -using ProtoBuf; -using Xunit; -using ProtoGenerated = Scada; -using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack; - -/// -/// Verifies wire compatibility between Host proto-generated types and Client code-first types. -/// Serializes with one stack, deserializes with the other. -/// -public class CrossStackSerializationTests -{ - // ── Proto-generated → Code-first ────────────────────────── - - [Fact] - public void VtqMessage_ProtoToCodeFirst_BoolValue() - { - // Arrange: proto-generated VtqMessage with bool TypedValue - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Running", - Value = new ProtoGenerated.TypedValue { BoolValue = true }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - // Act: serialize with proto, deserialize with protobuf-net - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - // Assert - codeFirst.Should().NotBeNull(); - codeFirst.Tag.Should().Be("Motor.Running"); - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.BoolValue.Should().BeTrue(); - codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L); - codeFirst.Quality.Should().NotBeNull(); - codeFirst.Quality!.StatusCode.Should().Be(0x00000000u); - codeFirst.Quality.SymbolicName.Should().Be("Good"); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_DoubleValue() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Speed", - Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.DoubleValue.Should().Be(42.5); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_StringValue() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Name", - Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.StringValue.Should().Be("Pump A"); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_Int32Value() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Count", - Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value!.Int32Value.Should().Be(int.MaxValue); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_BadQuality() - { - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Fault", - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u); - codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure"); - codeFirst.Quality.IsBad.Should().BeTrue(); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_NullValue() - { - // No Value field set — represents null - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Optional", - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - // When no oneof is set, the Value object may be null or all-default - // Either way, GetValueCase() should return None - if (codeFirst.Value != null) - codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None); - } - - [Fact] - public void VtqMessage_ProtoToCodeFirst_FloatArrayValue() - { - var floatArr = new ProtoGenerated.FloatArray(); - floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f }); - var protoMsg = new ProtoGenerated.VtqMessage - { - Tag = "Motor.Samples", - Value = new ProtoGenerated.TypedValue - { - ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr } - }, - TimestampUtcTicks = 638789000000000000L, - Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - - codeFirst.Value.Should().NotBeNull(); - codeFirst.Value!.ArrayValue.Should().NotBeNull(); - codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull(); - codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f }); - } - - // ── Code-first → Proto-generated ────────────────────────── - - [Fact] - public void VtqMessage_CodeFirstToProto_DoubleValue() - { - var codeFirst = new CodeFirst.VtqMessage - { - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 99.9 }, - TimestampUtcTicks = 638789000000000000L, - Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" } - }; - - // Serialize with protobuf-net - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var bytes = ms.ToArray(); - - // Deserialize with Google.Protobuf - var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes); - - protoMsg.Tag.Should().Be("Motor.Speed"); - protoMsg.Value.Should().NotBeNull(); - protoMsg.Value.DoubleValue.Should().Be(99.9); - protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L); - protoMsg.Quality.StatusCode.Should().Be(0x00000000u); - } - - [Fact] - public void WriteRequest_CodeFirstToProto() - { - var codeFirst = new CodeFirst.WriteRequest - { - SessionId = "abc123", - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 42.5 } - }; - - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var bytes = ms.ToArray(); - - var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes); - protoMsg.SessionId.Should().Be("abc123"); - protoMsg.Tag.Should().Be("Motor.Speed"); - protoMsg.Value.Should().NotBeNull(); - protoMsg.Value.DoubleValue.Should().Be(42.5); - } - - [Fact] - public void ConnectRequest_RoundTrips() - { - var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" }; - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray()); - protoMsg.ClientId.Should().Be("ScadaLink-1"); - protoMsg.ApiKey.Should().Be("key-123"); - } - - [Fact] - public void ConnectResponse_RoundTrips() - { - var protoMsg = new ProtoGenerated.ConnectResponse - { - Success = true, - Message = "Connected", - SessionId = "abcdef1234567890abcdef1234567890" - }; - var bytes = protoMsg.ToByteArray(); - var codeFirst = Serializer.Deserialize(new MemoryStream(bytes)); - codeFirst.Success.Should().BeTrue(); - codeFirst.Message.Should().Be("Connected"); - codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890"); - } - - [Fact] - public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue() - { - var codeFirst = new CodeFirst.WriteBatchAndWaitRequest - { - SessionId = "sess1", - FlagTag = "Motor.Done", - FlagValue = new CodeFirst.TypedValue { BoolValue = true }, - TimeoutMs = 5000, - PollIntervalMs = 100, - Items = - { - new CodeFirst.WriteItem - { - Tag = "Motor.Speed", - Value = new CodeFirst.TypedValue { DoubleValue = 50.0 } - } - } - }; - - var ms = new MemoryStream(); - Serializer.Serialize(ms, codeFirst); - var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray()); - - protoMsg.FlagTag.Should().Be("Motor.Done"); - protoMsg.FlagValue.BoolValue.Should().BeTrue(); - protoMsg.TimeoutMs.Should().Be(5000); - protoMsg.PollIntervalMs.Should().Be(100); - protoMsg.Items.Should().HaveCount(1); - protoMsg.Items[0].Tag.Should().Be("Motor.Speed"); - protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs deleted file mode 100644 index aba3def..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class QualityExtensionsTests -{ - [Theory] - [InlineData(Quality.Good, true)] - [InlineData(Quality.Good_LocalOverride, true)] - [InlineData(Quality.Uncertain, false)] - [InlineData(Quality.Bad, false)] - public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected); - - [Theory] - [InlineData(Quality.Uncertain, true)] - [InlineData(Quality.Uncertain_LastUsable, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Bad, false)] - public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected); - - [Theory] - [InlineData(Quality.Bad, true)] - [InlineData(Quality.Bad_CommFailure, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Uncertain, false)] - public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected); -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs deleted file mode 100644 index 7e5c9a8..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class ScadaContractsTests -{ - [Fact] - public void TypedValue_GetValueCase_Bool() - { - var tv = new TypedValue { BoolValue = true }; - tv.GetValueCase().Should().Be(TypedValueCase.BoolValue); - } - - [Fact] - public void TypedValue_GetValueCase_Int32() - { - var tv = new TypedValue { Int32Value = 42 }; - tv.GetValueCase().Should().Be(TypedValueCase.Int32Value); - } - - [Fact] - public void TypedValue_GetValueCase_Double() - { - var tv = new TypedValue { DoubleValue = 3.14 }; - tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue); - } - - [Fact] - public void TypedValue_GetValueCase_String() - { - var tv = new TypedValue { StringValue = "hello" }; - tv.GetValueCase().Should().Be(TypedValueCase.StringValue); - } - - [Fact] - public void TypedValue_GetValueCase_None_WhenDefault() - { - var tv = new TypedValue(); - tv.GetValueCase().Should().Be(TypedValueCase.None); - } - - [Fact] - public void TypedValue_GetValueCase_Datetime() - { - var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks }; - tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue); - } - - [Fact] - public void TypedValue_GetValueCase_BytesValue() - { - var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } }; - tv.GetValueCase().Should().Be(TypedValueCase.BytesValue); - } - - [Fact] - public void TypedValue_GetValueCase_ArrayValue() - { - var tv = new TypedValue - { - ArrayValue = new ArrayValue - { - FloatValues = new FloatArray { Values = { 1.0f, 2.0f } } - } - }; - tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue); - } - - [Fact] - public void QualityCode_IsGood() - { - var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }; - qc.IsGood.Should().BeTrue(); - qc.IsBad.Should().BeFalse(); - qc.IsUncertain.Should().BeFalse(); - } - - [Fact] - public void QualityCode_IsBad() - { - var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }; - qc.IsGood.Should().BeFalse(); - qc.IsBad.Should().BeTrue(); - qc.IsUncertain.Should().BeFalse(); - } - - [Fact] - public void QualityCode_IsUncertain() - { - var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" }; - qc.IsGood.Should().BeFalse(); - qc.IsBad.Should().BeFalse(); - qc.IsUncertain.Should().BeTrue(); - } - - [Fact] - public void VtqMessage_DefaultProperties() - { - var vtq = new VtqMessage(); - vtq.Tag.Should().BeEmpty(); - vtq.Value.Should().BeNull(); - vtq.TimestampUtcTicks.Should().Be(0); - vtq.Quality.Should().BeNull(); - } - - [Fact] - public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue() - { - var req = new WriteBatchAndWaitRequest - { - SessionId = "abc", - FlagTag = "Motor.Done", - FlagValue = new TypedValue { BoolValue = true }, - TimeoutMs = 5000, - PollIntervalMs = 100 - }; - req.FlagValue.Should().NotBeNull(); - req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue); - } - - [Fact] - public void WriteItem_Value_IsTypedValue() - { - var item = new WriteItem - { - Tag = "Motor.Speed", - Value = new TypedValue { DoubleValue = 42.5 } - }; - item.Value.Should().NotBeNull(); - item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs deleted file mode 100644 index 8d1965e..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain; - -public class VtqTests -{ - [Fact] - public void Good_FactoryMethod() - { - var vtq = Vtq.Good(42.0); - vtq.Value.Should().Be(42.0); - vtq.Quality.Should().Be(Quality.Good); - vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc); - } - - [Fact] - public void Bad_FactoryMethod() - { - var vtq = Vtq.Bad(); - vtq.Value.Should().BeNull(); - vtq.Quality.Should().Be(Quality.Bad); - } - - [Fact] - public void Uncertain_FactoryMethod() - { - var vtq = Vtq.Uncertain("stale"); - vtq.Value.Should().Be("stale"); - vtq.Quality.Should().Be(Quality.Uncertain); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeLmxProxyClient.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeLmxProxyClient.cs deleted file mode 100644 index e44e361..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeLmxProxyClient.cs +++ /dev/null @@ -1,91 +0,0 @@ -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -/// -/// Hand-written fake implementation of ILmxProxyClient for unit testing streaming extensions. -/// -internal class FakeLmxProxyClient : ILmxProxyClient -{ - public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30); - - // Track calls - public List> ReadBatchCalls { get; } = []; - public List> WriteBatchCalls { get; } = []; - public List> SubscribeCalls { get; } = []; - - // Configurable responses - public Func, CancellationToken, Task>>? ReadBatchHandler { get; set; } - public Exception? ReadBatchExceptionToThrow { get; set; } - public int ReadBatchExceptionCount { get; set; } - private int _readBatchCallCount; - - // Subscription support - public Action? CapturedOnUpdate { get; private set; } - public Action? CapturedOnError { get; private set; } - - public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task DisconnectAsync() => Task.CompletedTask; - public Task IsConnectedAsync() => Task.FromResult(true); - - public Task ReadAsync(string address, CancellationToken cancellationToken = default) - => Task.FromResult(new Vtq(null, DateTime.UtcNow, Quality.Good)); - - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken cancellationToken = default) - { - var addressList = addresses.ToList(); - ReadBatchCalls.Add(addressList); - _readBatchCallCount++; - - if (ReadBatchExceptionToThrow is not null && _readBatchCallCount <= ReadBatchExceptionCount) - throw ReadBatchExceptionToThrow; - - if (ReadBatchHandler is not null) - return ReadBatchHandler(addressList, cancellationToken); - - var result = new Dictionary(); - foreach (var addr in addressList) - result[addr] = new Vtq(42.0, DateTime.UtcNow, Quality.Good); - return Task.FromResult>(result); - } - - public Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default) - => Task.CompletedTask; - - public Task WriteBatchAsync(IDictionary values, CancellationToken cancellationToken = default) - { - WriteBatchCalls.Add(new Dictionary(values)); - return Task.CompletedTask; - } - - public Task WriteBatchAndWaitAsync( - IDictionary values, string flagTag, TypedValue flagValue, - int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default) - => Task.FromResult(new WriteBatchAndWaitResponse { Success = true }); - - public Task SubscribeAsync( - IEnumerable addresses, - Action onUpdate, - Action? onStreamError = null, - CancellationToken cancellationToken = default) - { - SubscribeCalls.Add(addresses); - CapturedOnUpdate = onUpdate; - CapturedOnError = onStreamError; - return Task.FromResult(new FakeSubscription()); - } - - public Task CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default) - => Task.FromResult(new LmxProxyClient.ApiKeyInfo { IsValid = true }); - - public Dictionary GetMetrics() => []; - - public void Dispose() { } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - private class FakeSubscription : LmxProxyClient.ISubscription - { - public void Dispose() { } - public Task DisposeAsync() => Task.CompletedTask; - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeScadaService.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeScadaService.cs deleted file mode 100644 index d1b2dae..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/FakeScadaService.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Runtime.CompilerServices; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -/// -/// Hand-written fake implementation of IScadaService for unit testing. -/// -internal class FakeScadaService : IScadaService -{ - // Configure responses - public ConnectResponse ConnectResponseToReturn { get; set; } = new() { Success = true, SessionId = "test-session-123", Message = "OK" }; - public DisconnectResponse DisconnectResponseToReturn { get; set; } = new() { Success = true, Message = "OK" }; - public GetConnectionStateResponse GetConnectionStateResponseToReturn { get; set; } = new() { IsConnected = true }; - public ReadResponse ReadResponseToReturn { get; set; } = new() { Success = true }; - public ReadBatchResponse ReadBatchResponseToReturn { get; set; } = new() { Success = true }; - public WriteResponse WriteResponseToReturn { get; set; } = new() { Success = true }; - public WriteBatchResponse WriteBatchResponseToReturn { get; set; } = new() { Success = true }; - public WriteBatchAndWaitResponse WriteBatchAndWaitResponseToReturn { get; set; } = new() { Success = true }; - public CheckApiKeyResponse CheckApiKeyResponseToReturn { get; set; } = new() { IsValid = true, Message = "Valid" }; - - // Track calls - public List ConnectCalls { get; } = []; - public List DisconnectCalls { get; } = []; - public List GetConnectionStateCalls { get; } = []; - public List ReadCalls { get; } = []; - public List ReadBatchCalls { get; } = []; - public List WriteCalls { get; } = []; - public List WriteBatchCalls { get; } = []; - public List WriteBatchAndWaitCalls { get; } = []; - public List CheckApiKeyCalls { get; } = []; - public List SubscribeCalls { get; } = []; - - // Error injection - public Exception? GetConnectionStateException { get; set; } - - // Subscription data - public List SubscriptionMessages { get; set; } = []; - public Exception? SubscriptionException { get; set; } - - public ValueTask ConnectAsync(ConnectRequest request) - { - ConnectCalls.Add(request); - return new ValueTask(ConnectResponseToReturn); - } - - public ValueTask DisconnectAsync(DisconnectRequest request) - { - DisconnectCalls.Add(request); - return new ValueTask(DisconnectResponseToReturn); - } - - public ValueTask GetConnectionStateAsync(GetConnectionStateRequest request) - { - GetConnectionStateCalls.Add(request); - if (GetConnectionStateException is not null) - throw GetConnectionStateException; - return new ValueTask(GetConnectionStateResponseToReturn); - } - - public ValueTask ReadAsync(ReadRequest request) - { - ReadCalls.Add(request); - return new ValueTask(ReadResponseToReturn); - } - - public ValueTask ReadBatchAsync(ReadBatchRequest request) - { - ReadBatchCalls.Add(request); - return new ValueTask(ReadBatchResponseToReturn); - } - - public ValueTask WriteAsync(WriteRequest request) - { - WriteCalls.Add(request); - return new ValueTask(WriteResponseToReturn); - } - - public ValueTask WriteBatchAsync(WriteBatchRequest request) - { - WriteBatchCalls.Add(request); - return new ValueTask(WriteBatchResponseToReturn); - } - - public ValueTask WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request) - { - WriteBatchAndWaitCalls.Add(request); - return new ValueTask(WriteBatchAndWaitResponseToReturn); - } - - public ValueTask CheckApiKeyAsync(CheckApiKeyRequest request) - { - CheckApiKeyCalls.Add(request); - return new ValueTask(CheckApiKeyResponseToReturn); - } - - public async IAsyncEnumerable SubscribeAsync( - SubscribeRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - SubscribeCalls.Add(request); - - foreach (var msg in SubscriptionMessages) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return msg; - await Task.Yield(); - } - - if (SubscriptionException is not null) - throw SubscriptionException; - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/TestableClient.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/TestableClient.cs deleted file mode 100644 index d46cda2..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/Fakes/TestableClient.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -/// -/// Helper to create an LmxProxyClient wired to a FakeScadaService, bypassing real gRPC. -/// Uses reflection to set private fields since the client has no test seam for IScadaService injection. -/// -internal static class TestableClient -{ - /// - /// Creates an LmxProxyClient with a fake service injected into its internal state, - /// simulating a connected client. - /// - public static (LmxProxyClient Client, FakeScadaService Fake) CreateConnected( - string sessionId = "test-session-123", - ILogger? logger = null) - { - var fake = new FakeScadaService - { - ConnectResponseToReturn = new ConnectResponse - { - Success = true, - SessionId = sessionId, - Message = "OK" - } - }; - - var client = new LmxProxyClient("localhost", 50051, "test-key", null, logger); - - // Use reflection to inject fake service and simulate connected state - var clientType = typeof(LmxProxyClient); - - var clientField = clientType.GetField("_client", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; - clientField.SetValue(client, fake); - - var sessionField = clientType.GetField("_sessionId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; - sessionField.SetValue(client, sessionId); - - var connectedField = clientType.GetField("_isConnected", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; - connectedField.SetValue(client, true); - - return (client, fake); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs deleted file mode 100644 index fc15300..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class LmxProxyClientBuilderTests -{ - [Fact] - public void Build_ThrowsWhenHostNotSet() - { - var builder = new LmxProxyClientBuilder(); - Assert.Throws(() => builder.Build()); - } - - [Fact] - public void Build_DefaultPort_Is50051() - { - var client = new LmxProxyClientBuilder() - .WithHost("localhost") - .Build(); - Assert.NotNull(client); - } - - [Fact] - public void WithPort_ThrowsOnZero() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithPort(0)); - } - - [Fact] - public void WithPort_ThrowsOn65536() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithPort(65536)); - } - - [Fact] - public void WithTimeout_ThrowsOnNegative() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1))); - } - - [Fact] - public void WithTimeout_ThrowsOver10Minutes() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11))); - } - - [Fact] - public void WithRetryPolicy_ThrowsOnZeroAttempts() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1))); - } - - [Fact] - public void WithRetryPolicy_ThrowsOnZeroDelay() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero)); - } - - [Fact] - public void Build_WithAllOptions_Succeeds() - { - var client = new LmxProxyClientBuilder() - .WithHost("10.100.0.48") - .WithPort(50051) - .WithApiKey("test-key") - .WithTimeout(TimeSpan.FromSeconds(15)) - .WithRetryPolicy(5, TimeSpan.FromSeconds(2)) - .WithMetrics() - .WithCorrelationIdHeader("X-Correlation-ID") - .Build(); - Assert.NotNull(client); - } - - [Fact] - public void Build_WithTls_ValidatesCertificatePaths() - { - var builder = new LmxProxyClientBuilder() - .WithHost("localhost") - .WithTlsConfiguration(new ClientTlsConfiguration - { - UseTls = true, - ServerCaCertificatePath = "/nonexistent/cert.pem" - }); - Assert.Throws(() => builder.Build()); - } - - [Fact] - public void WithHost_ThrowsOnNull() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithHost(null!)); - } - - [Fact] - public void WithHost_ThrowsOnEmpty() - { - Assert.Throws(() => - new LmxProxyClientBuilder().WithHost("")); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs deleted file mode 100644 index 710da79..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class LmxProxyClientConnectionTests -{ - [Fact] - public async Task IsConnectedAsync_ReturnsFalseBeforeConnect() - { - var client = new LmxProxyClient("localhost", 50051, null, null); - - var result = await client.IsConnectedAsync(); - - result.Should().BeFalse(); - client.Dispose(); - } - - [Fact] - public async Task IsConnectedAsync_ReturnsTrueAfterInjection() - { - var (client, _) = TestableClient.CreateConnected(); - - var result = await client.IsConnectedAsync(); - - result.Should().BeTrue(); - client.Dispose(); - } - - [Fact] - public async Task DisconnectAsync_SendsDisconnectAndClearsState() - { - var (client, fake) = TestableClient.CreateConnected(); - - await client.DisconnectAsync(); - - fake.DisconnectCalls.Should().HaveCount(1); - fake.DisconnectCalls[0].SessionId.Should().Be("test-session-123"); - client.IsConnected.Should().BeFalse(); - client.Dispose(); - } - - [Fact] - public async Task DisconnectAsync_SwallowsExceptions() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.DisconnectResponseToReturn = null!; // Force an error path - - // Should not throw - var act = () => client.DisconnectAsync(); - await act.Should().NotThrowAsync(); - - client.Dispose(); - } - - [Fact] - public void IsConnected_ReturnsFalseAfterDispose() - { - var (client, _) = TestableClient.CreateConnected(); - - client.Dispose(); - - client.IsConnected.Should().BeFalse(); - } - - [Fact] - public async Task MarkDisconnectedAsync_ClearsConnectionState() - { - var (client, _) = TestableClient.CreateConnected(); - - await client.MarkDisconnectedAsync(new Exception("connection lost")); - - client.IsConnected.Should().BeFalse(); - client.Dispose(); - } - - [Fact] - public void DefaultTimeout_RejectsOutOfRange() - { - var client = new LmxProxyClient("localhost", 50051, null, null); - - var act = () => client.DefaultTimeout = TimeSpan.FromMilliseconds(500); - act.Should().Throw(); - - var act2 = () => client.DefaultTimeout = TimeSpan.FromMinutes(11); - act2.Should().Throw(); - - client.Dispose(); - } - - [Fact] - public void DefaultTimeout_AcceptsValidRange() - { - var client = new LmxProxyClient("localhost", 50051, null, null); - - client.DefaultTimeout = TimeSpan.FromSeconds(5); - client.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(5)); - - client.Dispose(); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs deleted file mode 100644 index 3b936a3..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class LmxProxyClientFactoryTests -{ - [Fact] - public void CreateClient_BindsFromConfiguration() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["LmxProxy:Host"] = "10.100.0.48", - ["LmxProxy:Port"] = "50052", - ["LmxProxy:ApiKey"] = "test-key", - ["LmxProxy:Retry:MaxAttempts"] = "5", - ["LmxProxy:Retry:Delay"] = "00:00:02", - }) - .Build(); - - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient(); - Assert.NotNull(client); - } - - [Fact] - public void CreateClient_NamedSection() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["MyProxy:Host"] = "10.100.0.48", - ["MyProxy:Port"] = "50052", - }) - .Build(); - - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient("MyProxy"); - Assert.NotNull(client); - } - - [Fact] - public void CreateClient_BuilderAction() - { - var config = new ConfigurationBuilder().Build(); - var factory = new LmxProxyClientFactory(config); - var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051)); - Assert.NotNull(client); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs deleted file mode 100644 index 4569d51..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs +++ /dev/null @@ -1,177 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class LmxProxyClientReadWriteTests -{ - [Fact] - public async Task ReadAsync_ReturnsVtqFromResponse() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.ReadResponseToReturn = new ReadResponse - { - Success = true, - Vtq = new VtqMessage - { - Tag = "TestTag", - Value = new TypedValue { DoubleValue = 42.5 }, - TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - } - }; - - var result = await client.ReadAsync("TestTag"); - - result.Value.Should().Be(42.5); - result.Quality.Should().Be(Quality.Good); - fake.ReadCalls.Should().HaveCount(1); - fake.ReadCalls[0].Tag.Should().Be("TestTag"); - fake.ReadCalls[0].SessionId.Should().Be("test-session-123"); - client.Dispose(); - } - - [Fact] - public async Task ReadAsync_ThrowsOnFailureResponse() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.ReadResponseToReturn = new ReadResponse { Success = false, Message = "Tag not found" }; - - var act = () => client.ReadAsync("BadTag"); - - await act.Should().ThrowAsync() - .WithMessage("*Tag not found*"); - client.Dispose(); - } - - [Fact] - public async Task ReadAsync_ThrowsWhenNotConnected() - { - var client = new LmxProxyClient("localhost", 50051, null, null); - - var act = () => client.ReadAsync("AnyTag"); - - await act.Should().ThrowAsync() - .WithMessage("*not connected*"); - client.Dispose(); - } - - [Fact] - public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.ReadBatchResponseToReturn = new ReadBatchResponse - { - Success = true, - Vtqs = - [ - new VtqMessage - { - Tag = "Tag1", - Value = new TypedValue { Int32Value = 100 }, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - }, - new VtqMessage - { - Tag = "Tag2", - Value = new TypedValue { BoolValue = true }, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - } - ] - }; - - var result = await client.ReadBatchAsync(["Tag1", "Tag2"]); - - result.Should().HaveCount(2); - result["Tag1"].Value.Should().Be(100); - result["Tag2"].Value.Should().Be(true); - client.Dispose(); - } - - [Fact] - public async Task WriteAsync_SendsTypedValueDirectly() - { - var (client, fake) = TestableClient.CreateConnected(); - var typedValue = new TypedValue { DoubleValue = 99.9 }; - - await client.WriteAsync("TestTag", typedValue); - - fake.WriteCalls.Should().HaveCount(1); - fake.WriteCalls[0].Tag.Should().Be("TestTag"); - fake.WriteCalls[0].Value.Should().NotBeNull(); - fake.WriteCalls[0].Value!.DoubleValue.Should().Be(99.9); - client.Dispose(); - } - - [Fact] - public async Task WriteAsync_ThrowsOnFailureResponse() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.WriteResponseToReturn = new WriteResponse { Success = false, Message = "Write error" }; - - var act = () => client.WriteAsync("Tag", new TypedValue { Int32Value = 1 }); - - await act.Should().ThrowAsync() - .WithMessage("*Write error*"); - client.Dispose(); - } - - [Fact] - public async Task WriteBatchAsync_SendsAllItems() - { - var (client, fake) = TestableClient.CreateConnected(); - var values = new Dictionary - { - ["Tag1"] = new TypedValue { DoubleValue = 1.0 }, - ["Tag2"] = new TypedValue { Int32Value = 2 }, - ["Tag3"] = new TypedValue { BoolValue = true } - }; - - await client.WriteBatchAsync(values); - - fake.WriteBatchCalls.Should().HaveCount(1); - fake.WriteBatchCalls[0].Items.Should().HaveCount(3); - client.Dispose(); - } - - [Fact] - public async Task WriteBatchAndWaitAsync_ReturnsResponse() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.WriteBatchAndWaitResponseToReturn = new WriteBatchAndWaitResponse - { - Success = true, - FlagReached = true, - ElapsedMs = 150, - WriteResults = [new WriteResult { Tag = "Tag1", Success = true }] - }; - var values = new Dictionary - { - ["Tag1"] = new TypedValue { Int32Value = 1 } - }; - - var result = await client.WriteBatchAndWaitAsync( - values, "FlagTag", new TypedValue { BoolValue = true }); - - result.FlagReached.Should().BeTrue(); - result.ElapsedMs.Should().Be(150); - client.Dispose(); - } - - [Fact] - public async Task CheckApiKeyAsync_ReturnsApiKeyInfo() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.CheckApiKeyResponseToReturn = new CheckApiKeyResponse { IsValid = true, Message = "Admin key" }; - - var result = await client.CheckApiKeyAsync("my-api-key"); - - result.IsValid.Should().BeTrue(); - result.Description.Should().Be("Admin key"); - client.Dispose(); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs deleted file mode 100644 index 23d632f..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class LmxProxyClientSubscriptionTests -{ - [Fact] - public async Task SubscribeAsync_InvokesCallbackForEachUpdate() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.SubscriptionMessages = - [ - new VtqMessage - { - Tag = "Tag1", - Value = new TypedValue { DoubleValue = 1.0 }, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - }, - new VtqMessage - { - Tag = "Tag2", - Value = new TypedValue { Int32Value = 42 }, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - } - ]; - - var updates = new List<(string Tag, Vtq Vtq)>(); - var subscription = await client.SubscribeAsync( - ["Tag1", "Tag2"], - (tag, vtq) => updates.Add((tag, vtq))); - - // Wait for processing to complete (fake yields all then stops) - await Task.Delay(500); - - updates.Should().HaveCount(2); - updates[0].Tag.Should().Be("Tag1"); - updates[0].Vtq.Value.Should().Be(1.0); - updates[1].Tag.Should().Be("Tag2"); - updates[1].Vtq.Value.Should().Be(42); - - subscription.Dispose(); - client.Dispose(); - } - - [Fact] - public async Task SubscribeAsync_InvokesStreamErrorOnFailure() - { - var (client, fake) = TestableClient.CreateConnected(); - fake.SubscriptionException = new InvalidOperationException("Stream broke"); - - Exception? capturedError = null; - var subscription = await client.SubscribeAsync( - ["Tag1"], - (_, _) => { }, - ex => capturedError = ex); - - // Wait for error to propagate - await Task.Delay(500); - - capturedError.Should().NotBeNull(); - capturedError.Should().BeOfType(); - capturedError!.Message.Should().Be("Stream broke"); - - subscription.Dispose(); - client.Dispose(); - } - - [Fact] - public async Task SubscribeAsync_DisposeStopsProcessing() - { - var (client, fake) = TestableClient.CreateConnected(); - // Provide many messages but we'll dispose early - fake.SubscriptionMessages = - [ - new VtqMessage - { - Tag = "Tag1", - Value = new TypedValue { DoubleValue = 1.0 }, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - } - ]; - - var updates = new List<(string Tag, Vtq Vtq)>(); - var subscription = await client.SubscribeAsync( - ["Tag1"], - (tag, vtq) => updates.Add((tag, vtq))); - - // Dispose immediately - subscription.Dispose(); - - // Should not throw - client.Dispose(); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ServiceCollectionExtensionsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ServiceCollectionExtensionsTests.cs deleted file mode 100644 index 890e3f0..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class ServiceCollectionExtensionsTests -{ - [Fact] - public void AddLmxProxyClient_WithConfiguration_RegistersSingleton() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["LmxProxy:Host"] = "localhost", - ["LmxProxy:Port"] = "50051", - }) - .Build(); - - var services = new ServiceCollection(); - services.AddLmxProxyClient(config); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - Assert.NotNull(client); - Assert.IsType(client); - } - - [Fact] - public void AddLmxProxyClient_WithBuilderAction_RegistersSingleton() - { - var services = new ServiceCollection(); - services.AddLmxProxyClient(b => b.WithHost("localhost").WithPort(50051)); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - Assert.NotNull(client); - } - - [Fact] - public void AddLmxProxyClient_WithNamedSection_RegistersSingleton() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["CustomProxy:Host"] = "10.0.0.1", - ["CustomProxy:Port"] = "50052", - }) - .Build(); - - var services = new ServiceCollection(); - services.AddLmxProxyClient(config, "CustomProxy"); - - using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); - Assert.NotNull(client); - } - - [Fact] - public void AddScopedLmxProxyClient_RegistersScoped() - { - var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["LmxProxy:Host"] = "localhost", - }) - .Build(); - - var services = new ServiceCollection(); - services.AddScopedLmxProxyClient(config); - - using var provider = services.BuildServiceProvider(); - using var scope = provider.CreateScope(); - var client = scope.ServiceProvider.GetRequiredService(); - Assert.NotNull(client); - } - - [Fact] - public void AddNamedLmxProxyClient_RegistersKeyedSingleton() - { - var services = new ServiceCollection(); - services.AddNamedLmxProxyClient("primary", b => b.WithHost("host-a").WithPort(50051)); - services.AddNamedLmxProxyClient("secondary", b => b.WithHost("host-b").WithPort(50052)); - - using var provider = services.BuildServiceProvider(); - var primary = provider.GetRequiredKeyedService("primary"); - var secondary = provider.GetRequiredKeyedService("secondary"); - Assert.NotNull(primary); - Assert.NotNull(secondary); - Assert.NotSame(primary, secondary); - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs deleted file mode 100644 index 383d0bc..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; -using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class StreamingExtensionsTests -{ - [Fact] - public async Task ReadStreamAsync_BatchesCorrectly() - { - var fake = new FakeLmxProxyClient(); - var addresses = Enumerable.Range(0, 250).Select(i => $"tag{i}").ToList(); - - var results = new List>(); - await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 100)) - { - results.Add(kvp); - } - - // 250 tags at batchSize=100 => 3 batch calls (100, 100, 50) - Assert.Equal(3, fake.ReadBatchCalls.Count); - Assert.Equal(100, fake.ReadBatchCalls[0].Count); - Assert.Equal(100, fake.ReadBatchCalls[1].Count); - Assert.Equal(50, fake.ReadBatchCalls[2].Count); - Assert.Equal(250, results.Count); - } - - [Fact] - public async Task ReadStreamAsync_RetriesOnError() - { - var fake = new FakeLmxProxyClient - { - ReadBatchExceptionToThrow = new InvalidOperationException("transient"), - ReadBatchExceptionCount = 1 // First call throws, second succeeds - }; - - var addresses = Enumerable.Range(0, 5).Select(i => $"tag{i}").ToList(); - var results = new List>(); - await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 10)) - { - results.Add(kvp); - } - - // Should retry: first call throws, second succeeds - Assert.Equal(2, fake.ReadBatchCalls.Count); - Assert.Equal(5, results.Count); - } - - [Fact] - public async Task WriteStreamAsync_BatchesAndReturnsCount() - { - var fake = new FakeLmxProxyClient(); - var values = GenerateWriteValues(250); - - int total = await fake.WriteStreamAsync(values, batchSize: 100); - - Assert.Equal(250, total); - Assert.Equal(3, fake.WriteBatchCalls.Count); - Assert.Equal(100, fake.WriteBatchCalls[0].Count); - Assert.Equal(100, fake.WriteBatchCalls[1].Count); - Assert.Equal(50, fake.WriteBatchCalls[2].Count); - } - - [Fact] - public async Task ProcessInParallelAsync_RespectsMaxConcurrency() - { - int maxConcurrency = 2; - int currentConcurrency = 0; - int maxObservedConcurrency = 0; - var lockObj = new object(); - - var source = GenerateAsyncSequence(10); - - await source.ProcessInParallelAsync(async (item, ct) => - { - int current; - lock (lockObj) - { - currentConcurrency++; - current = currentConcurrency; - if (current > maxObservedConcurrency) - maxObservedConcurrency = current; - } - - await Task.Delay(50, ct); - - lock (lockObj) - { - currentConcurrency--; - } - }, maxConcurrency: maxConcurrency); - - Assert.True(maxObservedConcurrency <= maxConcurrency, - $"Max observed concurrency {maxObservedConcurrency} exceeded limit {maxConcurrency}"); - } - - [Fact] - public async Task SubscribeStreamAsync_YieldsFromChannel() - { - var fake = new FakeLmxProxyClient(); - var addresses = new[] { "tag1", "tag2" }; - using var cts = new CancellationTokenSource(); - - var results = new List<(string Tag, Vtq Vtq)>(); - - // Start the subscription stream in a background task - var streamTask = Task.Run(async () => - { - await foreach (var item in fake.SubscribeStreamAsync(addresses, cts.Token)) - { - results.Add(item); - if (results.Count >= 3) - await cts.CancelAsync(); - } - }); - - // Wait for subscribe to be called with a polling loop - for (int i = 0; i < 50 && fake.CapturedOnUpdate is null; i++) - await Task.Delay(50); - - // Simulate updates via captured callback - Assert.NotNull(fake.CapturedOnUpdate); - fake.CapturedOnUpdate!("tag1", new Vtq(1.0, DateTime.UtcNow, Quality.Good)); - fake.CapturedOnUpdate!("tag2", new Vtq(2.0, DateTime.UtcNow, Quality.Good)); - fake.CapturedOnUpdate!("tag1", new Vtq(3.0, DateTime.UtcNow, Quality.Good)); - - // Wait for stream task to complete (cancelled after 3 items) - try { await streamTask; } - catch (OperationCanceledException) { } - - Assert.Equal(3, results.Count); - Assert.Equal("tag1", results[0].Tag); - Assert.Equal("tag2", results[1].Tag); - Assert.Equal("tag1", results[2].Tag); - } - - private static async IAsyncEnumerable> GenerateWriteValues(int count) - { - for (int i = 0; i < count; i++) - { - yield return new KeyValuePair( - $"tag{i}", - new TypedValue { DoubleValue = i * 1.0 }); - await Task.Yield(); - } - } - - private static async IAsyncEnumerable GenerateAsyncSequence(int count) - { - for (int i = 0; i < count; i++) - { - yield return i; - await Task.Yield(); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs deleted file mode 100644 index 0bdf7ab..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Client.Domain; - -namespace ZB.MOM.WW.LmxProxy.Client.Tests; - -public class TypedValueConversionTests -{ - [Fact] - public void ConvertVtqMessage_ExtractsBoolValue() - { - var msg = CreateVtqMessage(new TypedValue { BoolValue = true }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be(true); - } - - [Fact] - public void ConvertVtqMessage_ExtractsInt32Value() - { - var msg = CreateVtqMessage(new TypedValue { Int32Value = 42 }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be(42); - } - - [Fact] - public void ConvertVtqMessage_ExtractsInt64Value() - { - var msg = CreateVtqMessage(new TypedValue { Int64Value = long.MaxValue }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be(long.MaxValue); - } - - [Fact] - public void ConvertVtqMessage_ExtractsFloatValue() - { - var msg = CreateVtqMessage(new TypedValue { FloatValue = 3.14f }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be(3.14f); - } - - [Fact] - public void ConvertVtqMessage_ExtractsDoubleValue() - { - var msg = CreateVtqMessage(new TypedValue { DoubleValue = 99.99 }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be(99.99); - } - - [Fact] - public void ConvertVtqMessage_ExtractsStringValue() - { - var msg = CreateVtqMessage(new TypedValue { StringValue = "hello" }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().Be("hello"); - } - - [Fact] - public void ConvertVtqMessage_ExtractsDateTimeValue() - { - var dt = new DateTime(2026, 3, 22, 12, 0, 0, DateTimeKind.Utc); - var msg = CreateVtqMessage(new TypedValue { DatetimeValue = dt.Ticks }); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().BeOfType(); - ((DateTime)vtq.Value!).Should().Be(dt); - } - - [Fact] - public void ConvertVtqMessage_HandlesNullTypedValue() - { - var msg = new VtqMessage - { - Tag = "NullTag", - Value = null, - TimestampUtcTicks = DateTime.UtcNow.Ticks, - Quality = new QualityCode { StatusCode = 0x00000000 } - }; - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Value.Should().BeNull(); - } - - [Fact] - public void ConvertVtqMessage_HandlesNullMessage() - { - var vtq = LmxProxyClient.ConvertVtqMessage(null); - - vtq.Value.Should().BeNull(); - vtq.Quality.Should().Be(Quality.Bad); - } - - [Fact] - public void ConvertVtqMessage_GoodQualityCode() - { - var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x00000000); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Quality.Should().Be(Quality.Good); - } - - [Fact] - public void ConvertVtqMessage_BadQualityCode() - { - var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x80000000); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Quality.Should().Be(Quality.Bad); - } - - [Fact] - public void ConvertVtqMessage_UncertainQualityCode() - { - var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x40000000); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Quality.Should().Be(Quality.Uncertain); - } - - [Fact] - public void ConvertVtqMessage_MapsQualityCodeCorrectly() - { - // Test that a specific non-zero Good code still maps to Good - var msg = CreateVtqMessage(new TypedValue { Int32Value = 5 }, statusCode: 0x00D80000); - - var vtq = LmxProxyClient.ConvertVtqMessage(msg); - - vtq.Quality.Should().Be(Quality.Good); - } - - private static VtqMessage CreateVtqMessage(TypedValue value, uint statusCode = 0x00000000) - { - return new VtqMessage - { - Tag = "TestTag", - Value = value, - TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks, - Quality = new QualityCode { StatusCode = statusCode } - }; - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj deleted file mode 100644 index 11ba36d..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - net10.0 - latest - enable - enable - false - ZB.MOM.WW.LmxProxy.Client.Tests - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs deleted file mode 100644 index f5720ac..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Configuration/ConfigurationValidatorTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Configuration; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration -{ - public class ConfigurationValidatorTests - { - private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration(); - - [Fact] - public void ValidConfig_PassesValidation() - { - var config = ValidConfig(); - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().NotThrow(); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(70000)] - public void InvalidGrpcPort_Throws(int port) - { - var config = ValidConfig(); - config.GrpcPort = port; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("GrpcPort")); - } - - [Fact] - public void InvalidMonitorInterval_Throws() - { - var config = ValidConfig(); - config.Connection.MonitorIntervalSeconds = 0; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("MonitorIntervalSeconds")); - } - - [Fact] - public void InvalidChannelCapacity_Throws() - { - var config = ValidConfig(); - config.Subscription.ChannelCapacity = -1; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ChannelCapacity")); - } - - [Fact] - public void InvalidChannelFullMode_Throws() - { - var config = ValidConfig(); - config.Subscription.ChannelFullMode = "InvalidMode"; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ChannelFullMode")); - } - - [Fact] - public void InvalidResetPeriodDays_Throws() - { - var config = ValidConfig(); - config.ServiceRecovery.ResetPeriodDays = 0; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("ResetPeriodDays")); - } - - [Fact] - public void NegativeFailureDelay_Throws() - { - var config = ValidConfig(); - config.ServiceRecovery.FirstFailureDelayMinutes = -1; - Action act = () => ConfigurationValidator.ValidateAndLog(config); - act.Should().Throw().Where(e => e.Message.Contains("FirstFailureDelayMinutes")); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs deleted file mode 100644 index b6d23bb..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class QualityCodeMapperTests - { - [Theory] - [InlineData(Quality.Good, 0x00000000u, "Good")] - [InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")] - [InlineData(Quality.Bad, 0x80000000u, "Bad")] - [InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")] - [InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")] - [InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")] - [InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")] - [InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")] - [InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")] - [InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")] - [InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")] - [InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")] - [InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")] - [InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")] - public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName) - { - var qc = QualityCodeMapper.ToQualityCode(quality); - qc.StatusCode.Should().Be(expectedStatusCode); - qc.SymbolicName.Should().Be(expectedName); - } - - [Theory] - [InlineData(0x00000000u, Quality.Good)] - [InlineData(0x80000000u, Quality.Bad)] - [InlineData(0x80040000u, Quality.Bad_ConfigError)] - [InlineData(0x806D0000u, Quality.Bad_SensorFailure)] - [InlineData(0x40900000u, Quality.Uncertain_LastUsable)] - public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality) - { - QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality); - } - - [Fact] - public void FromStatusCode_UnknownGoodCode_FallsBackToGood() - { - QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good); - } - - [Fact] - public void FromStatusCode_UnknownBadCode_FallsBackToBad() - { - QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad); - } - - [Fact] - public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain() - { - QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain); - } - - [Theory] - [InlineData(0x00000000u, "Good")] - [InlineData(0x80000000u, "Bad")] - [InlineData(0x806D0000u, "BadSensorFailure")] - [InlineData(0x40900000u, "UncertainLastUsableValue")] - [InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back - public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName) - { - QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName); - } - - [Fact] - public void FactoryMethods_ReturnCorrectCodes() - { - QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u); - QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u); - QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u); - QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u); - QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u); - QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u); - QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u); - QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u); - QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u); - QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u); - QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs deleted file mode 100644 index 53b727c..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class QualityExtensionsTests - { - [Theory] - [InlineData(Quality.Good, true)] - [InlineData(Quality.Good_LocalOverride, true)] - [InlineData(Quality.Uncertain, false)] - [InlineData(Quality.Bad, false)] - public void IsGood(Quality q, bool expected) - { - q.IsGood().Should().Be(expected); - } - - [Theory] - [InlineData(Quality.Uncertain, true)] - [InlineData(Quality.Uncertain_LastUsable, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Bad, false)] - public void IsUncertain(Quality q, bool expected) - { - q.IsUncertain().Should().Be(expected); - } - - [Theory] - [InlineData(Quality.Bad, true)] - [InlineData(Quality.Bad_CommFailure, true)] - [InlineData(Quality.Good, false)] - [InlineData(Quality.Uncertain, false)] - public void IsBad(Quality q, bool expected) - { - q.IsBad().Should().Be(expected); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs deleted file mode 100644 index 2ed347c..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain -{ - public class TypedValueConverterTests - { - [Fact] - public void Null_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(null); - tv.Should().BeNull(); - TypedValueConverter.FromTypedValue(null).Should().BeNull(); - } - - [Fact] - public void DBNull_MapsToNull() - { - var tv = TypedValueConverter.ToTypedValue(DBNull.Value); - tv.Should().BeNull(); - } - - [Fact] - public void Bool_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(true); - tv.Should().NotBeNull(); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue); - tv.BoolValue.Should().BeTrue(); - TypedValueConverter.FromTypedValue(tv).Should().Be(true); - - var tvFalse = TypedValueConverter.ToTypedValue(false); - tvFalse!.BoolValue.Should().BeFalse(); - TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false); - } - - [Fact] - public void Short_WidensToInt32() - { - var tv = TypedValueConverter.ToTypedValue((short)42); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(42); - TypedValueConverter.FromTypedValue(tv).Should().Be(42); - } - - [Fact] - public void Int_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(int.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(int.MaxValue); - TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue); - } - - [Fact] - public void Long_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(long.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(long.MaxValue); - TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue); - } - - [Fact] - public void UShort_WidensToInt32() - { - var tv = TypedValueConverter.ToTypedValue((ushort)65535); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value); - tv.Int32Value.Should().Be(65535); - } - - [Fact] - public void UInt_WidensToInt64() - { - var tv = TypedValueConverter.ToTypedValue(uint.MaxValue); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(uint.MaxValue); - } - - [Fact] - public void ULong_MapsToInt64() - { - var tv = TypedValueConverter.ToTypedValue((ulong)12345678); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value); - tv.Int64Value.Should().Be(12345678); - } - - [Fact] - public void Float_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(3.14159f); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue); - tv.FloatValue.Should().Be(3.14159f); - TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f); - } - - [Fact] - public void Double_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue(2.718281828459045); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); - tv.DoubleValue.Should().Be(2.718281828459045); - TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045); - } - - [Fact] - public void String_RoundTrips() - { - var tv = TypedValueConverter.ToTypedValue("Hello World"); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); - tv.StringValue.Should().Be("Hello World"); - TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World"); - } - - [Fact] - public void DateTime_RoundTrips_AsUtcTicks() - { - var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc); - var tv = TypedValueConverter.ToTypedValue(dt); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue); - tv.DatetimeValue.Should().Be(dt.Ticks); - var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!; - result.Kind.Should().Be(DateTimeKind.Utc); - result.Ticks.Should().Be(dt.Ticks); - } - - [Fact] - public void ByteArray_RoundTrips() - { - var bytes = new byte[] { 0x00, 0xFF, 0x42 }; - var tv = TypedValueConverter.ToTypedValue(bytes); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue); - var result = (byte[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(bytes); - } - - [Fact] - public void Decimal_MapsToDouble() - { - var tv = TypedValueConverter.ToTypedValue(123.456m); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue); - tv.DoubleValue.Should().BeApproximately(123.456, 0.001); - } - - [Fact] - public void FloatArray_RoundTrips() - { - var arr = new float[] { 1.0f, 2.0f, 3.0f }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (float[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void IntArray_RoundTrips() - { - var arr = new int[] { 10, 20, 30 }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (int[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void StringArray_RoundTrips() - { - var arr = new string[] { "a", "b", "c" }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (string[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void DoubleArray_RoundTrips() - { - var arr = new double[] { 1.1, 2.2, 3.3 }; - var tv = TypedValueConverter.ToTypedValue(arr); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue); - var result = (double[])TypedValueConverter.FromTypedValue(tv)!; - result.Should().BeEquivalentTo(arr); - } - - [Fact] - public void UnrecognizedType_FallsBackToString() - { - var guid = Guid.NewGuid(); - var tv = TypedValueConverter.ToTypedValue(guid); - tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue); - tv.StringValue.Should().Be(guid.ToString()); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs deleted file mode 100644 index 0db7170..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Health; -using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health -{ - public class HealthCheckServiceTests - { - private class FakeScadaClient : IScadaClient - { - public bool IsConnected { get; set; } = true; - public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected; - public DateTime ConnectedSince => DateTime.UtcNow; - public int ReconnectCount => 0; - public event EventHandler? ConnectionStateChanged; - public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task ReadAsync(string address, CancellationToken ct = default) => - Task.FromResult(Vtq.Good(42.0)); - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => - Task.FromResult>(new Dictionary()); - public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; - public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; - public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, string flagTag, object flagValue, - int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => - Task.FromResult((false, 0)); - public Task UnsubscribeByAddressAsync(IEnumerable addresses) => Task.CompletedTask; - public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => - Task.FromResult(new FakeHandle()); - public ValueTask DisposeAsync() => default; - internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); - private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; } - } - - [Fact] - public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics() - { - var client = new FakeScadaClient { IsConnected = true, ConnectionState = ConnectionState.Connected }; - using var sm = new SubscriptionManager(client); - using var pm = new PerformanceMetrics(); - pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); - - var svc = new HealthCheckService(client, sm, pm); - - var result = await svc.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - } - - [Fact] - public async Task ReturnsUnhealthy_WhenNotConnected() - { - var client = new FakeScadaClient { IsConnected = false, ConnectionState = ConnectionState.Disconnected }; - using var sm = new SubscriptionManager(client); - using var pm = new PerformanceMetrics(); - - var svc = new HealthCheckService(client, sm, pm); - - var result = await svc.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Unhealthy); - result.Description.Should().Contain("not connected"); - } - - [Fact] - public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent() - { - var client = new FakeScadaClient { IsConnected = true }; - using var sm = new SubscriptionManager(client); - using var pm = new PerformanceMetrics(); - - // Record 200 operations with 40% success rate - for (int i = 0; i < 80; i++) - pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); - for (int i = 0; i < 120; i++) - pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false); - - var svc = new HealthCheckService(client, sm, pm); - - var result = await svc.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Degraded); - result.Description.Should().Contain("success rate"); - } - - [Fact] - public async Task ReturnsDegraded_WhenClientCountOver100() - { - var client = new FakeScadaClient { IsConnected = true }; - using var sm = new SubscriptionManager(client); - using var pm = new PerformanceMetrics(); - - // Create 101 subscriptions to exceed the threshold - for (int i = 0; i < 101; i++) - { - using var cts = new CancellationTokenSource(); - await sm.SubscribeAsync("client-" + i, new[] { "tag1" }, cts.Token); - } - - var svc = new HealthCheckService(client, sm, pm); - - var result = await svc.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Degraded); - result.Description.Should().Contain("client count"); - } - - [Fact] - public async Task DoesNotFlagLowSuccessRate_Under100Operations() - { - var client = new FakeScadaClient { IsConnected = true }; - using var sm = new SubscriptionManager(client); - using var pm = new PerformanceMetrics(); - - // Record 50 operations with 0% success rate (under 100 threshold) - for (int i = 0; i < 50; i++) - pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false); - - var svc = new HealthCheckService(client, sm, pm); - - var result = await svc.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - } - } - -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs deleted file mode 100644 index 1c36f5c..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Metrics; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics -{ - public class PerformanceMetricsTests - { - [Fact] - public void RecordOperation_TracksCountAndDuration() - { - using var metrics = new PerformanceMetrics(); - - for (int i = 0; i < 5; i++) - { - metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true); - } - - var stats = metrics.GetStatistics(); - stats.Should().ContainKey("TestOp"); - stats["TestOp"].TotalCount.Should().Be(5); - } - - [Fact] - public void RecordOperation_TracksSuccessAndFailure() - { - using var metrics = new PerformanceMetrics(); - - for (int i = 0; i < 3; i++) - { - metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true); - } - for (int i = 0; i < 2; i++) - { - metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), false); - } - - var stats = metrics.GetStatistics(); - stats["TestOp"].SuccessRate.Should().BeApproximately(0.6, 0.001); - } - - [Fact] - public void GetStatistics_CalculatesP95Correctly() - { - using var metrics = new PerformanceMetrics(); - - for (int i = 1; i <= 100; i++) - { - metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true); - } - - var stats = metrics.GetStatistics(); - stats["TestOp"].Percentile95Milliseconds.Should().BeApproximately(95.0, 1.0); - } - - [Fact] - public void RollingBuffer_CapsAt1000Samples() - { - using var metrics = new PerformanceMetrics(); - - for (int i = 0; i < 1500; i++) - { - metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true); - } - - var stats = metrics.GetStatistics(); - // TotalCount tracks all 1500 but percentile is computed from the last 1000 - stats["TestOp"].TotalCount.Should().Be(1500); - // The rolling buffer should have entries from 500-1499 - // P95 of 500..1499 should be around 1449 - stats["TestOp"].Percentile95Milliseconds.Should().BeGreaterThan(1000); - } - - [Fact] - public void BeginOperation_RecordsDurationOnDispose() - { - using var metrics = new PerformanceMetrics(); - - using (var scope = metrics.BeginOperation("TestOp")) - { - System.Threading.Thread.Sleep(50); - } - - var stats = metrics.GetStatistics(); - stats.Should().ContainKey("TestOp"); - stats["TestOp"].TotalCount.Should().Be(1); - stats["TestOp"].AverageMilliseconds.Should().BeGreaterOrEqualTo(40); - } - - [Fact] - public void TimingScope_DefaultsToSuccess() - { - using var metrics = new PerformanceMetrics(); - - using (metrics.BeginOperation("TestOp")) - { - // Do nothing — default is success - } - - var stats = metrics.GetStatistics(); - stats["TestOp"].SuccessCount.Should().Be(1); - } - - [Fact] - public void TimingScope_RespectsSetSuccessFalse() - { - using var metrics = new PerformanceMetrics(); - - using (var scope = metrics.BeginOperation("TestOp")) - { - scope.SetSuccess(false); - } - - var stats = metrics.GetStatistics(); - stats["TestOp"].SuccessCount.Should().Be(0); - stats["TestOp"].TotalCount.Should().Be(1); - } - - [Fact] - public void GetMetrics_ReturnsNullForUnknownOperation() - { - using var metrics = new PerformanceMetrics(); - - var result = metrics.GetMetrics("DoesNotExist"); - - result.Should().BeNull(); - } - - [Fact] - public void GetAllMetrics_ReturnsAllTrackedOperations() - { - using var metrics = new PerformanceMetrics(); - - metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true); - metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20), true); - metrics.RecordOperation("Subscribe", TimeSpan.FromMilliseconds(5), true); - - var all = metrics.GetAllMetrics(); - all.Should().ContainKey("Read"); - all.Should().ContainKey("Write"); - all.Should().ContainKey("Subscribe"); - all.Count.Should().Be(3); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs deleted file mode 100644 index 980ab27..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/MxAccess/TypedValueEqualsTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess -{ - public class TypedValueEqualsTests - { - [Fact] - public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue(); - - [Fact] - public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse(); - - [Fact] - public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse(); - - [Fact] - public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue(); - - [Fact] - public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse(); - - [Fact] - public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); - - [Fact] - public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue(); - - [Fact] - public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse(); - - [Fact] - public void String_CaseSensitive() - { - TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue(); - TypedValueComparer.Equals("done", "DONE").Should().BeFalse(); - } - - [Fact] - public void Array_SameElements() - { - TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue(); - } - - [Fact] - public void Array_DifferentElements() - { - TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse(); - } - - [Fact] - public void Array_DifferentLengths() - { - TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse(); - } - - [Fact] - public void Int32_NotEqual_ToDouble() - { - TypedValueComparer.Equals(1, 1.0).Should().BeFalse(); - } - - [Fact] - public void Long_Equality() - { - TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue(); - } - - [Fact] - public void DateTime_TickPrecision() - { - var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); - var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc); - TypedValueComparer.Equals(dt1, dt2).Should().BeTrue(); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs deleted file mode 100644 index 982ea9e..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyInterceptorTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FluentAssertions; -using Xunit; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security -{ - public class ApiKeyInterceptorTests - { - [Theory] - [InlineData("/scada.ScadaService/Write")] - [InlineData("/scada.ScadaService/WriteBatch")] - [InlineData("/scada.ScadaService/WriteBatchAndWait")] - public void WriteProtectedMethods_AreCorrectlyDefined(string method) - { - // This test verifies the set of write-protected methods is correct. - // The actual interceptor logic is tested via integration tests. - var writeProtected = new System.Collections.Generic.HashSet( - System.StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - writeProtected.Should().Contain(method); - } - - [Theory] - [InlineData("/scada.ScadaService/Connect")] - [InlineData("/scada.ScadaService/Disconnect")] - [InlineData("/scada.ScadaService/GetConnectionState")] - [InlineData("/scada.ScadaService/Read")] - [InlineData("/scada.ScadaService/ReadBatch")] - [InlineData("/scada.ScadaService/Subscribe")] - [InlineData("/scada.ScadaService/CheckApiKey")] - public void ReadMethods_AreNotWriteProtected(string method) - { - var writeProtected = new System.Collections.Generic.HashSet( - System.StringComparer.OrdinalIgnoreCase) - { - "/scada.ScadaService/Write", - "/scada.ScadaService/WriteBatch", - "/scada.ScadaService/WriteBatchAndWait" - }; - writeProtected.Should().NotContain(method); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs deleted file mode 100644 index bea4ca8..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Security/ApiKeyServiceTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.IO; -using FluentAssertions; -using Newtonsoft.Json; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Security; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security -{ - public class ApiKeyServiceTests : IDisposable - { - private readonly string _tempDir; - - public ApiKeyServiceTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - - private string CreateKeyFile(params ApiKey[] keys) - { - var path = Path.Combine(_tempDir, "apikeys.json"); - var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List(keys) }; - File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented)); - return path; - } - - [Fact] - public void AutoGeneratesDefaultFile_WhenMissing() - { - var path = Path.Combine(_tempDir, "missing.json"); - using (var svc = new ApiKeyService(path)) - { - File.Exists(path).Should().BeTrue(); - svc.KeyCount.Should().Be(2); - } - } - - [Fact] - public void ValidateApiKey_ReturnsKey_WhenValid() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - var key = svc.ValidateApiKey("test-key"); - key.Should().NotBeNull(); - key!.Role.Should().Be(ApiKeyRole.ReadWrite); - } - } - - [Fact] - public void ValidateApiKey_ReturnsNull_WhenInvalid() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - svc.ValidateApiKey("wrong-key").Should().BeNull(); - } - } - - [Fact] - public void ValidateApiKey_ReturnsNull_WhenDisabled() - { - var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false }); - using (var svc = new ApiKeyService(path)) - { - svc.ValidateApiKey("test-key").Should().BeNull(); - } - } - - [Fact] - public void HasRole_ReadWrite_CanRead() - { - var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue(); - } - } - - [Fact] - public void HasRole_ReadOnly_CannotWrite() - { - var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse(); - } - } - - [Fact] - public void HasRole_ReadWrite_CanWrite() - { - var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue(); - } - } - - [Fact] - public void ValidateApiKey_EmptyString_ReturnsNull() - { - var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true }); - using (var svc = new ApiKeyService(path)) - { - svc.ValidateApiKey("").Should().BeNull(); - svc.ValidateApiKey(null!).Should().BeNull(); - } - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs deleted file mode 100644 index 9000ef4..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Sessions/SessionManagerTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Sessions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions -{ - public class SessionManagerTests - { - [Fact] - public void CreateSession_Returns32CharHexId() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("client1", "key1"); - id.Should().HaveLength(32); - id.Should().MatchRegex("^[0-9a-f]{32}$"); - } - - [Fact] - public void CreateSession_IncrementsCount() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.ActiveSessionCount.Should().Be(0); - sm.CreateSession("c1", "k1"); - sm.ActiveSessionCount.Should().Be(1); - sm.CreateSession("c2", "k2"); - sm.ActiveSessionCount.Should().Be(2); - } - - [Fact] - public void ValidateSession_ReturnsTrueForExistingSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - sm.ValidateSession(id).Should().BeTrue(); - } - - [Fact] - public void ValidateSession_ReturnsFalseForUnknownSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.ValidateSession("nonexistent").Should().BeFalse(); - } - - [Fact] - public void ValidateSession_UpdatesLastActivity() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - var session = sm.GetSession(id); - var initialActivity = session!.LastActivity; - - Thread.Sleep(50); // Small delay to ensure time passes - sm.ValidateSession(id); - - session.LastActivity.Should().BeAfter(initialActivity); - } - - [Fact] - public void TerminateSession_RemovesSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - sm.TerminateSession(id).Should().BeTrue(); - sm.ActiveSessionCount.Should().Be(0); - sm.ValidateSession(id).Should().BeFalse(); - } - - [Fact] - public void TerminateSession_ReturnsFalseForUnknownSession() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.TerminateSession("nonexistent").Should().BeFalse(); - } - - [Fact] - public void GetSession_ReturnsNullForUnknown() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.GetSession("nonexistent").Should().BeNull(); - } - - [Fact] - public void GetSession_ReturnsCorrectInfo() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("client-abc", "key-xyz"); - var session = sm.GetSession(id); - session.Should().NotBeNull(); - session!.ClientId.Should().Be("client-abc"); - session.ApiKey.Should().Be("key-xyz"); - session.SessionId.Should().Be(id); - session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); - } - - [Fact] - public void GetAllSessions_ReturnsSnapshot() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.CreateSession("c1", "k1"); - sm.CreateSession("c2", "k2"); - var all = sm.GetAllSessions(); - all.Should().HaveCount(2); - } - - [Fact] - public async Task ConcurrentAccess_IsThreadSafe() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var tasks = new Task[100]; - for (int i = 0; i < 100; i++) - { - int idx = i; - tasks[i] = Task.Run(() => - { - var id = sm.CreateSession($"client-{idx}", $"key-{idx}"); - sm.ValidateSession(id); - if (idx % 3 == 0) sm.TerminateSession(id); - }); - } - await Task.WhenAll(tasks); - - // Should have ~67 sessions remaining (100 - ~33 terminated) - sm.ActiveSessionCount.Should().BeInRange(60, 70); - } - - [Fact] - public void Dispose_ClearsAllSessions() - { - var sm = new SessionManager(inactivityTimeoutMinutes: 0); - sm.CreateSession("c1", "k1"); - sm.CreateSession("c2", "k2"); - sm.Dispose(); - sm.ActiveSessionCount.Should().Be(0); - } - - [Fact] - public void ConnectedSinceUtcTicks_ReturnsCorrectValue() - { - using var sm = new SessionManager(inactivityTimeoutMinutes: 0); - var id = sm.CreateSession("c1", "k1"); - var session = sm.GetSession(id); - session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks); - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs deleted file mode 100644 index 4e1bcde..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Newtonsoft.Json.Linq; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Health; -using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService; -using ZB.MOM.WW.LmxProxy.Host.Metrics; -using ZB.MOM.WW.LmxProxy.Host.Status; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status -{ - public class StatusReportServiceTests - { - private class FakeScadaClient : IScadaClient - { - public bool IsConnected { get; set; } = true; - public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected; - public DateTime ConnectedSince => DateTime.UtcNow; - public int ReconnectCount => 0; - public event EventHandler? ConnectionStateChanged; - public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task ReadAsync(string address, CancellationToken ct = default) => - Task.FromResult(Vtq.Good(42.0)); - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => - Task.FromResult>(new Dictionary()); - public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; - public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; - public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, string flagTag, object flagValue, - int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => - Task.FromResult((false, 0)); - public Task UnsubscribeByAddressAsync(IEnumerable addresses) => Task.CompletedTask; - public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => - Task.FromResult(new FakeHandle()); - public ValueTask DisposeAsync() => default; - internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); - private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; } - } - - private (StatusReportService svc, PerformanceMetrics pm, SubscriptionManager sm) CreateService( - bool connected = true) - { - var client = new FakeScadaClient - { - IsConnected = connected, - ConnectionState = connected ? ConnectionState.Connected : ConnectionState.Disconnected - }; - var sm = new SubscriptionManager(client); - var pm = new PerformanceMetrics(); - var health = new HealthCheckService(client, sm, pm); - var svc = new StatusReportService(client, sm, pm, health); - return (svc, pm, sm); - } - - [Fact] - public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson() - { - var (svc, pm, sm) = CreateService(); - using (pm) using (sm) - { - var json = await svc.GenerateJsonReportAsync(); - - json.Should().Contain("\"serviceName\""); - json.Should().Contain("\"connection\""); - json.Should().Contain("\"isConnected\""); - } - } - - [Fact] - public async Task GenerateHtmlReportAsync_ContainsAutoRefresh() - { - var (svc, pm, sm) = CreateService(); - using (pm) using (sm) - { - var html = await svc.GenerateHtmlReportAsync(); - - html.Should().Contain(""); - } - } - - [Fact] - public async Task IsHealthyAsync_ReturnsTrueWhenHealthy() - { - var (svc, pm, sm) = CreateService(connected: true); - using (pm) using (sm) - { - var result = await svc.IsHealthyAsync(); - - result.Should().BeTrue(); - } - } - - [Fact] - public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy() - { - var (svc, pm, sm) = CreateService(connected: false); - using (pm) using (sm) - { - var result = await svc.IsHealthyAsync(); - - result.Should().BeFalse(); - } - } - - [Fact] - public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics() - { - var (svc, pm, sm) = CreateService(); - using (pm) using (sm) - { - pm.RecordOperation("Read", TimeSpan.FromMilliseconds(15), true); - pm.RecordOperation("Write", TimeSpan.FromMilliseconds(25), true); - - var json = await svc.GenerateJsonReportAsync(); - var parsed = JObject.Parse(json); - - var operations = parsed["performance"]?["operations"]; - operations.Should().NotBeNull(); - // Newtonsoft CamelCasePropertyNamesContractResolver camelCases dictionary keys - operations!["read"].Should().NotBeNull(); - operations!["write"].Should().NotBeNull(); - ((long)operations!["read"]!["totalCount"]!).Should().Be(1); - } - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs deleted file mode 100644 index 733b37d..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/Subscriptions/SubscriptionManagerTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using ZB.MOM.WW.LmxProxy.Host.Domain; -using ZB.MOM.WW.LmxProxy.Host.Subscriptions; - -namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions -{ - public class SubscriptionManagerTests - { - /// Fake IScadaClient for testing (no COM dependency). - private class FakeScadaClient : IScadaClient - { - public bool IsConnected => true; - public ConnectionState ConnectionState => ConnectionState.Connected; - public DateTime ConnectedSince => DateTime.UtcNow; - public int ReconnectCount => 0; - public event EventHandler? ConnectionStateChanged; - public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task ReadAsync(string address, CancellationToken ct = default) => - Task.FromResult(Vtq.Good(42.0)); - public Task> ReadBatchAsync(IEnumerable addresses, CancellationToken ct = default) => - Task.FromResult>(new Dictionary()); - public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask; - public Task WriteBatchAsync(IReadOnlyDictionary values, CancellationToken ct = default) => Task.CompletedTask; - public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync( - IReadOnlyDictionary values, string flagTag, object flagValue, - int timeoutMs, int pollIntervalMs, CancellationToken ct = default) => - Task.FromResult((false, 0)); - public Task UnsubscribeByAddressAsync(IEnumerable addresses) => Task.CompletedTask; - public Task SubscribeAsync(IEnumerable addresses, Action callback, CancellationToken ct = default) => - Task.FromResult(new FakeSubscriptionHandle()); - public ValueTask DisposeAsync() => default; - - // Suppress unused event warning - internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!); - - private class FakeSubscriptionHandle : IAsyncDisposable - { - public ValueTask DisposeAsync() => default; - } - } - - [Fact] - public async Task Subscribe_ReturnsChannelReader() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Tag1", "Tag2" }, cts.Token); - reader.Should().NotBeNull(); - subscriptionId.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task OnTagValueChanged_FansOutToSubscribedClients() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - - var vtq = Vtq.Good(42.0); - sm.OnTagValueChanged("Motor.Speed", vtq); - - var result = await reader.ReadAsync(cts.Token); - result.address.Should().Be("Motor.Speed"); - result.vtq.Value.Should().Be(42.0); - result.vtq.Quality.Should().Be(Quality.Good); - } - - [Fact] - public async Task OnTagValueChanged_MultipleClients_BothReceive() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader1, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - var (reader2, _) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token); - - sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0)); - - var r1 = await reader1.ReadAsync(cts.Token); - var r2 = await reader2.ReadAsync(cts.Token); - r1.vtq.Value.Should().Be(99.0); - r2.vtq.Value.Should().Be(99.0); - } - - [Fact] - public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0)); - - // Channel should be empty - reader.TryRead(out _).Should().BeFalse(); - } - - [Fact] - public async Task UnsubscribeSubscription_CompletesChannel() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.UnsubscribeSubscription(subscriptionId); - - // Channel should be completed - reader.Completion.IsCompleted.Should().BeTrue(); - } - - [Fact] - public async Task UnsubscribeSession_RemovesAllSubscriptions() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - - sm.UnsubscribeSession("client1"); - - var stats = sm.GetStats(); - stats.TotalClients.Should().Be(0); - stats.TotalTags.Should().Be(0); - } - - [Fact] - public async Task RefCounting_LastSubscriptionUnsubscribeRemovesTag() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (_, subId1) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - var (_, subId2) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token); - - sm.GetStats().TotalTags.Should().Be(1); - - sm.UnsubscribeSubscription(subId1); - sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed - - sm.UnsubscribeSubscription(subId2); - sm.GetStats().TotalTags.Should().Be(0); // last subscription gone - } - - [Fact] - public async Task NotifyDisconnection_SendsBadQualityToAll() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token); - - sm.NotifyDisconnection(); - - // Should receive 2 bad quality messages - reader.TryRead(out var r1).Should().BeTrue(); - r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected); - reader.TryRead(out var r2).Should().BeTrue(); - r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected); - } - - [Fact] - public async Task Backpressure_DropOldest_DropsWhenFull() - { - using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3); - using var cts = new CancellationTokenSource(); - var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token); - - // Fill the channel beyond capacity - for (int i = 0; i < 10; i++) - { - sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i)); - } - - // Should have exactly 3 messages (capacity limit) - int count = 0; - while (reader.TryRead(out _)) count++; - count.Should().Be(3); - } - - [Fact] - public async Task GetStats_ReturnsCorrectCounts() - { - using var sm = new SubscriptionManager(new FakeScadaClient()); - using var cts = new CancellationTokenSource(); - var (_, _) = await sm.SubscribeAsync("c1", new[] { "Tag1", "Tag2" }, cts.Token); - var (_, _) = await sm.SubscribeAsync("c2", new[] { "Tag2", "Tag3" }, cts.Token); - - var stats = sm.GetStats(); - stats.TotalClients.Should().Be(2); - stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3 - stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3 - } - } -} diff --git a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj b/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj deleted file mode 100644 index d19f829..0000000 --- a/deprecated/lmxproxy/tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net48 - 9.0 - enable - false - ZB.MOM.WW.LmxProxy.Host.Tests - x86 - x86 - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - diff --git a/deprecated/windev.md b/deprecated/windev.md index aaf444f..e0c89eb 100644 --- a/deprecated/windev.md +++ b/deprecated/windev.md @@ -170,10 +170,6 @@ Single network interface: |-----------|-----| | Ethernet0 | 10.100.0.48 (static) | -## Other Users with SSH Access - -The `sshus` user also has passwordless SSH access (used for LmxProxy operations). See `lmxproxy_protocol.md` for details on the LmxProxy service running on this machine. - ## Backup (Veeam) Veeam job "Backup WW_DEV_VM" on the Veeam server (10.100.0.30). Targets the NAS repo (`nfs41://10.50.0.25:/mnt/mypool/veeam`).