From 40ca4b69087d115773c1fc3de8a558ed8dc0e041 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:23:56 -0400 Subject: [PATCH] Add gateway central alarm monitor and StreamAlarms feed The gateway now monitors alarms continuously, independent of any client session, and fans the feed out to every client. GatewayAlarmMonitor is an always-on hosted service that owns one gateway-managed worker session dedicated to alarms: it subscribes the configured provider, caches the active-alarm set from the worker's transition events (reconciled periodically against the worker's authoritative snapshot), re-opens the session if the worker faults, and broadcasts to all subscribers. The new session-less StreamAlarms RPC opens with the current active-alarm snapshot, then streams live transitions; any number of clients fan out from the single monitor without opening a worker session. AcknowledgeAlarm is now session-less and routes through the monitor. The session-scoped QueryActiveAlarms RPC and the per-session alarm auto-subscribe hook are removed, along with the now-dead IAlarmRpcDispatcher trio; the dashboard Alarms tab reads the monitor's in-process cache directly. This intentionally reverses the v1 "no multi-subscriber fan-out" decision for the alarm subsystem. Contracts regenerated; gateway, dashboard and tests build clean, 94 alarm-affected tests pass, and the monitor is verified live. Language-client stubs are regenerated in a follow-up change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Generated/MxaccessGateway.cs | 865 ++++++++++++------ .../Generated/MxaccessGatewayGrpc.cs | 59 +- .../Protos/mxaccess_gateway.proto | 46 +- .../WorkerLiveMxAccessSmokeTests.cs | 6 +- .../AlarmsServiceCollectionExtensions.cs | 22 + .../Alarms/GatewayAlarmMonitor.cs | 693 ++++++++++++++ .../Alarms/IGatewayAlarmService.cs | 63 ++ .../Configuration/AlarmsOptions.cs | 46 +- .../Configuration/GatewayOptionsValidator.cs | 9 +- .../Dashboard/DashboardLiveDataService.cs | 50 +- src/MxGateway.Server/GatewayApplication.cs | 2 + .../Grpc/MxAccessGatewayService.cs | 69 +- .../Authorization/GatewayGrpcScopeResolver.cs | 2 +- .../Sessions/IAlarmRpcDispatcher.cs | 41 - .../Sessions/NotWiredAlarmRpcDispatcher.cs | 51 -- .../Sessions/SessionManager.cs | 98 -- .../SessionServiceCollectionExtensions.cs | 1 - .../Sessions/WorkerAlarmRpcDispatcher.cs | 229 ----- src/MxGateway.Server/appsettings.json | 2 +- .../ProtobufContractRoundTripTests.cs | 18 +- .../GatewayEndToEndFakeWorkerSmokeTests.cs | 3 +- .../MxAccessGatewayServiceConstraintTests.cs | 3 +- .../Grpc/MxAccessGatewayServiceTests.cs | 128 +-- .../NotWiredAlarmRpcDispatcherTests.cs | 52 -- .../SessionManagerAlarmAutoSubscribeTests.cs | 305 ------ .../Sessions/WorkerAlarmRpcDispatcherTests.cs | 393 -------- ...atewayGrpcAuthorizationInterceptorTests.cs | 11 +- .../GatewayGrpcScopeResolverTests.cs | 2 +- .../TestSupport/FakeGatewayAlarmService.cs | 54 ++ 29 files changed, 1586 insertions(+), 1737 deletions(-) create mode 100644 src/MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs create mode 100644 src/MxGateway.Server/Alarms/GatewayAlarmMonitor.cs create mode 100644 src/MxGateway.Server/Alarms/IGatewayAlarmService.cs delete mode 100644 src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs delete mode 100644 src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs delete mode 100644 src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs delete mode 100644 src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs delete mode 100644 src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs delete mode 100644 src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs create mode 100644 src/MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs diff --git a/src/MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/MxGateway.Contracts/Generated/MxaccessGateway.cs index 8ba521a..4c5cdbb 100644 --- a/src/MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -322,163 +322,166 @@ namespace MxGateway.Contracts.Proto { "b29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDW9wZXJhdG9yX3VzZXIYCiAB", "KAkSGAoQb3BlcmF0b3JfY29tbWVudBgLIAEoCRIzCg1jdXJyZW50X3ZhbHVl", "GAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEjEKC2xpbWl0", - "X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlIpIB", - "ChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBISCgpzZXNzaW9uX2lkGAEgASgJ", - "Eh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCRIcChRhbGFybV9mdWxs", - "X3JlZmVyZW5jZRgDIAEoCRIPCgdjb21tZW50GAQgASgJEhUKDW9wZXJhdG9y", - "X3VzZXIYBSABKAki8wEKFUFja25vd2xlZGdlQWxhcm1SZXBseRISCgpzZXNz", - "aW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0aW9uX2lkGAIgASgJEjwKD3Byb3Rv", - "Y29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9j", - "b2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIKBnN0YXR1cxgFIAEo", - "CzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eRIaChJkaWFn", - "bm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHQiagoYUXVlcnlBY3Rp", - "dmVBbGFybXNSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSHQoVY2xpZW50", - "X2NvcnJlbGF0aW9uX2lkGAIgASgJEhsKE2FsYXJtX2ZpbHRlcl9wcmVmaXgY", - "AyABKAki6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2VzcxgBIAEoBRI3Cghj", - "YXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXND", - "YXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14YWNjZXNzX2dhdGV3", - "YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQgASgFEhQKDHJhd19j", - "YXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkYBiABKAUSFwoPZGlh", - "Z25vc3RpY190ZXh0GAcgASgJIqcDCgdNeFZhbHVlEjIKCWRhdGFfdHlwZRgB", - "IAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlwZRIUCgx2YXJp", - "YW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIWCg5yYXdfZGlhZ25v", - "c3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgFEhQKCmJvb2xfdmFs", - "dWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgAEhUKC2ludDY0X3Zh", - "bHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJIABIWCgxkb3VibGVf", - "dmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyABKAlIABI1Cg90aW1l", - "c3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w", - "SAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYx", - "Lk14QXJyYXlIABITCglyYXdfdmFsdWUYEiABKAxIAEIGCgRraW5kIv4ECgdN", - "eEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRIS", - "CgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0K", - "FXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgK", - "IAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50", - "MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFy", - "cmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVl", - "cxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5", - "Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5T", - "dHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFj", - "Y2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVl", - "cxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2", - "YWx1ZXMiGwoJQm9vbEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFy", - "cmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgB", - "IAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVB", - "cnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVz", - "GAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdv", - "b2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVz", - "GAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFj", - "Y2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdl", - "GAIgASgJKp8LCg1NeENvbW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9V", - "TlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4K", - "Gk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9L", - "SU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAE", - "Eh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1B", - "TkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNF", - "EAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUK", - "IU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NP", - "TU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoX", - "TVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRf", - "QUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9D", - "T01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRF", - "X1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQ", - "EBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRN", - "WF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9D", - "T01NQU5EX0tJTkRfQUREX0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lO", - "RF9BRFZJU0VfSVRFTV9CVUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9W", - "RV9JVEVNX0JVTEsQFRInCiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lU", - "RU1fQlVMSxAWEiIKHk1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAX", - "EiQKIE1YX0NPTU1BTkRfS0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhf", - "Q09NTUFORF9LSU5EX1NVQlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5E", - "X0tJTkRfVU5TVUJTQ1JJQkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5E", - "X0FDS05PV0xFREdFX0FMQVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZ", - "X0FDVElWRV9BTEFSTVMQHBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVE", - "R0VfQUxBUk1fQllfTkFNRRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9C", - "VUxLEB4SHwobTVhfQ09NTUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhf", - "Q09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1B", - "TkRfS0lORF9XUklURV9TRUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9L", - "SU5EX1JFQURfQlVMSxAiEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQoh", - "TVhfQ09NTUFORF9LSU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09N", - "TUFORF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lO", - "RF9EUkFJTl9FVkVOVFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05f", - "V09SS0VSEGgq+QEKDU14RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZ", - "X1VOU1BFQ0lGSUVEEAASIgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hB", - "TkdFEAESJQohTVhfRVZFTlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAIS", - "JgoiTVhfRVZFTlRfRkFNSUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01Y", - "X0VWRU5UX0ZBTUlMWV9PTl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEEicKI01Y", - "X0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9UUkFOU0lUSU9OEAUqygEKE0FsYXJt", - "VHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BF", - "Q0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQoh", - "QUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1f", - "VFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9L", - "SU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFM", - "QVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NP", - "TkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RB", - "VEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lO", - "QUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NB", - "VEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VO", - "S05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFU", - "VVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9X", - "QVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05f", - "RVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9F", - "UlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJP", - "UhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUK", - "IU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NU", - "QVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJj", - "ZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RB", - "VFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFV", - "RVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19M", - "TVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwof", - "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRV", - "U19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1Y", - "X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAH", - "Kt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAA", - "EhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05P", - "X0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFf", - "VFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhf", - "REFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIV", - "ChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VE", - "X1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhN", - "WF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5V", - "TRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9F", - "TlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwob", - "TVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBF", - "X1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJ", - "T05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQ", - "EhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29k", - "ZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BS", - "T1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NP", - "REVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVf", - "U0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9T", - "RVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dP", - "UktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJ", - "TUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisK", - "J1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikK", - "JVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoM", - "U2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIa", - "ChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9T", - "VEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9S", - "X1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VT", - "U0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9T", - "VEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNF", - "U1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVE", - "EAky4AQKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3Nf", - "Z2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIo", - "Lm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14", - "YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tl", - "EiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhh", - "Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVu", - "dHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3Qa", - "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRn", - "ZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJt", - "UmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFy", - "bVJlcGx5Em4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0ubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3QaKC5teGFjY2Vzc19nYXRl", - "d2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUIcqgIZTXhHYXRld2F5LkNv", - "bnRyYWN0cy5Qcm90b2IGcHJvdG8z")); + "X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlIpAB", + "ChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBIdChVjbGllbnRfY29ycmVsYXRp", + "b25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9yZWZlcmVuY2UYAyABKAkSDwoH", + "Y29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91c2VyGAUgASgJSgQIARACUgpz", + "ZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFsYXJtUmVwbHkSFgoOY29ycmVs", + "YXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xfc3RhdHVzGAMgASgLMiMubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0GAQg", + "ASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVzc2FnZRgGIAEoCUIK", + "CghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9pZCJRChNTdHJlYW1BbGFybXNS", + "ZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgBIAEoCRIbChNhbGFy", + "bV9maWx0ZXJfcHJlZml4GAIgASgJIr8BChBBbGFybUZlZWRNZXNzYWdlEkAK", + "DGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0", + "aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBzaG90X2NvbXBsZXRlGAIgASgI", + "SAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEu", + "T25BbGFybVRyYW5zaXRpb25FdmVudEgAQgkKB3BheWxvYWQi6wEKDU14U3Rh", + "dHVzUHJveHkSDwoHc3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIl", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRl", + "Y3RlZF9ieRgDIAEoDjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNT", + "b3VyY2USDgoGZGV0YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIX", + "Cg9yYXdfZGV0ZWN0ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcg", + "ASgJIqcDCgdNeFZhbHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNz", + "X2dhdGV3YXkudjEuTXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkS", + "DwoHaXNfbnVsbBgDIAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1y", + "YXdfZGF0YV90eXBlGAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtp", + "bnQzMl92YWx1ZRgLIAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoL", + "ZmxvYXRfdmFsdWUYDSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIW", + "CgxzdHJpbmdfdmFsdWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECAB", + "KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFs", + "dWUYESABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCgly", + "YXdfdmFsdWUYEiABKAxIAEIGCgRraW5kIv4ECgdNeEFycmF5EjoKEWVsZW1l", + "bnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERh", + "dGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1lbnNpb25zGAMg", + "AygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19lbGVtZW50X2Rh", + "dGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNz", + "X2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFsdWVzGAsgASgL", + "Mh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAASNwoMaW50NjRf", + "dmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5", + "SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVz", + "GA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdBcnJheUgAEj8K", + "EHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYx", + "LlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFy", + "cmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgB", + "IAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFy", + "cmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMY", + "ASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRpbWVz", + "dGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", + "aW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlByb3Rv", + "Y29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5LnYx", + "LlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1NeENv", + "bW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAAEhwK", + "GE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRfS0lO", + "RF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNEAMS", + "HQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1BTkRf", + "S0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQ", + "BhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09NTUFO", + "RF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRfS0lO", + "RF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9TRVRf", + "QlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9LSU5E", + "X1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZChVN", + "WF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRfV1JJ", + "VEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5N", + "WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01NQU5E", + "X0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJTkRf", + "QVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRfQURE", + "X0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRFTV9C", + "VUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQFRIn", + "CiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIKHk1Y", + "X0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1BTkRf", + "S0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5EX1NV", + "QlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJ", + "QkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdFX0FM", + "QVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFSTVMQ", + "HBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllfTkFN", + "RRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhfQ09N", + "TUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5EX1dS", + "SVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklURV9T", + "RUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVMSxAi", + "EhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9LSU5E", + "X0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dFVF9X", + "T1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVOVFMQ", + "ZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgq+QEKDU14", + "RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VOU1BFQ0lGSUVEEAAS", + "IgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdFEAESJQohTVhfRVZF", + "TlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoiTVhfRVZFTlRfRkFN", + "SUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VWRU5UX0ZBTUlMWV9P", + "Tl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEEicKI01YX0VWRU5UX0ZBTUlMWV9P", + "Tl9BTEFSTV9UUkFOU0lUSU9OEAUqygEKE0FsYXJtVHJhbnNpdGlvbktpbmQS", + "JQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASHwobQUxB", + "Uk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQohQUxBUk1fVFJBTlNJVElP", + "Tl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1fVFJBTlNJVElPTl9LSU5E", + "X0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JFVFJJR0dFUhAE", + "KqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFMQVJNX0NPTkRJVElPTl9T", + "VEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NPTkRJVElPTl9TVEFURV9B", + "Q1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RBVEVfQUNUSVZFX0FDS0VE", + "EAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lOQUNUSVZFEAMqpQMKEE14", + "U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX1VOU1BFQ0lG", + "SUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VOS05PV04QARIZChVNWF9T", + "VEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFUVVNfQ0FURUdPUllfUEVO", + "RElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9XQVJOSU5HEAQSKgomTVhf", + "U1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05fRVJST1IQBRIqCiZNWF9T", + "VEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9FUlJPUhAGEigKJE1YX1NU", + "QVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJPUhAHEiUKIU1YX1NUQVRV", + "U19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUKIU1YX1NUQVRVU19DQVRF", + "R09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NUQVRVU19DQVRFR09SWV9P", + "VEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJjZRIgChxNWF9TVEFUVVNf", + "U09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RBVFVTX1NPVVJDRV9VTktO", + "T1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0xNWBACEiMK", + "H01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19MTVgQAxIjCh9NWF9TVEFU", + "VVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwofTVhfU1RBVFVTX1NPVVJD", + "RV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRVU19TT1VSQ0VfUkVRVUVT", + "VElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1YX1NUQVRVU19TT1VSQ0Vf", + "UkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAHKt0ECgpNeERhdGFUeXBl", + "EhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAAEhgKFE1YX0RBVEFfVFlQ", + "RV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05PX0RBVEEQAhIYChRNWF9E", + "QVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFfVFlQRV9JTlRFR0VSEAQS", + "FgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhfREFUQV9UWVBFX0RPVUJM", + "RRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIVChFNWF9EQVRBX1RZUEVf", + "VElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VEX1RJTUUQCRIfChtNWF9E", + "QVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhNWF9EQVRBX1RZUEVfU1RB", + "VFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5VTRAMEi0KKU1YX0RBVEFf", + "VFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9FTlVNEA0SIgoeTVhfREFU", + "QV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwobTVhfREFUQV9UWVBFX1FV", + "QUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBFX1FVQUxJRklFRF9TVFJV", + "Q1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJT05BTElaRURfU1RSSU5H", + "EBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQEhIUChBNWF9EQVRBX1RZ", + "UEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29kZRIkCiBQUk9UT0NPTF9T", + "VEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BST1RPQ09MX1NUQVRVU19D", + "T0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NPREVfSU5WQUxJRF9SRVFV", + "RVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfRk9V", + "TkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9SRUFE", + "WRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dPUktFUl9VTkFWQUlMQUJM", + "RRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJTUVPVVQQBhIhCh1QUk9U", + "T0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisKJ1BST1RPQ09MX1NUQVRV", + "U19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikKJVBST1RPQ09MX1NUQVRV", + "U19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoMU2Vzc2lvblN0YXRlEh0K", + "GVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIaChZTRVNTSU9OX1NUQVRF", + "X0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9TVEFSVElOR19XT1JLRVIQ", + "AhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9SX1BJUEUQAxIdChlTRVNT", + "SU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VTU0lPTl9TVEFURV9JTklU", + "SUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9TVEFURV9SRUFEWRAGEhkK", + "FVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNFU1NJT05fU1RBVEVfQ0xP", + "U0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVEEAky0wQKD014QWNjZXNz", + "R2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNjZXNzX2dhdGV3YXkudjEu", + "T3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVu", + "U2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIoLm14YWNjZXNzX2dhdGV3", + "YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14YWNjZXNzX2dhdGV3YXku", + "djEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tlEiUubXhhY2Nlc3NfZ2F0", + "ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVudHMSKC5teGFjY2Vzc19n", + "YXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3QaHC5teGFjY2Vzc19nYXRl", + "d2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRnZUFsYXJtEiwubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVxdWVzdBoqLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFybVJlcGx5EmEKDFN0cmVh", + "bUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3RyZWFtQWxhcm1zUmVx", + "dWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1GZWVkTWVzc2FnZTAB", + "QhyqAhlNeEdhdGV3YXkuQ29udHJhY3RzLlByb3RvYgZwcm90bzM=")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -559,9 +562,10 @@ namespace MxGateway.Contracts.Proto { new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "SessionId", "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest), global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser, new[]{ "SessionId", "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamAlarmsRequest), global::MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.AlarmFeedMessage), global::MxGateway.Contracts.Proto.AlarmFeedMessage.Parser, new[]{ "ActiveAlarm", "SnapshotComplete", "Transition" }, new[]{ "Payload" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxStatusProxy), global::MxGateway.Contracts.Proto.MxStatusProxy.Parser, new[]{ "Success", "Category", "DetectedBy", "Detail", "RawCategory", "RawDetectedBy", "DiagnosticText" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxValue), global::MxGateway.Contracts.Proto.MxValue.Parser, new[]{ "DataType", "VariantType", "IsNull", "RawDiagnostic", "RawDataType", "BoolValue", "Int32Value", "Int64Value", "FloatValue", "DoubleValue", "StringValue", "TimestampValue", "ArrayValue", "RawValue" }, new[]{ "Kind" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.MxArray), global::MxGateway.Contracts.Proto.MxArray.Parser, new[]{ "ElementDataType", "VariantType", "Dimensions", "RawDiagnostic", "RawElementDataType", "BoolValues", "Int32Values", "Int64Values", "FloatValues", "DoubleValues", "StringValues", "TimestampValues", "RawValues" }, new[]{ "Values" }, null, null, null), @@ -25428,7 +25432,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public AcknowledgeAlarmRequest(AcknowledgeAlarmRequest other) : this() { - sessionId_ = other.sessionId_; clientCorrelationId_ = other.clientCorrelationId_; alarmFullReference_ = other.alarmFullReference_; comment_ = other.comment_; @@ -25442,18 +25445,6 @@ namespace MxGateway.Contracts.Proto { return new AcknowledgeAlarmRequest(this); } - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - /// Field number for the "client_correlation_id" field. public const int ClientCorrelationIdFieldNumber = 2; private string clientCorrelationId_ = ""; @@ -25527,7 +25518,6 @@ namespace MxGateway.Contracts.Proto { if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (ClientCorrelationId != other.ClientCorrelationId) return false; if (AlarmFullReference != other.AlarmFullReference) return false; if (Comment != other.Comment) return false; @@ -25539,7 +25529,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (ClientCorrelationId.Length != 0) hash ^= ClientCorrelationId.GetHashCode(); if (AlarmFullReference.Length != 0) hash ^= AlarmFullReference.GetHashCode(); if (Comment.Length != 0) hash ^= Comment.GetHashCode(); @@ -25562,10 +25551,6 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(ClientCorrelationId); @@ -25592,10 +25577,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(ClientCorrelationId); @@ -25622,9 +25603,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (ClientCorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(ClientCorrelationId); } @@ -25649,9 +25627,6 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.ClientCorrelationId.Length != 0) { ClientCorrelationId = other.ClientCorrelationId; } @@ -25683,10 +25658,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { ClientCorrelationId = input.ReadString(); break; @@ -25722,10 +25693,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { ClientCorrelationId = input.ReadString(); break; @@ -25786,7 +25753,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public AcknowledgeAlarmReply(AcknowledgeAlarmReply other) : this() { _hasBits0 = other._hasBits0; - sessionId_ = other.sessionId_; correlationId_ = other.correlationId_; protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null; hresult_ = other.hresult_; @@ -25801,18 +25767,6 @@ namespace MxGateway.Contracts.Proto { return new AcknowledgeAlarmReply(this); } - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - /// Field number for the "correlation_id" field. public const int CorrelationIdFieldNumber = 2; private string correlationId_ = ""; @@ -25918,7 +25872,6 @@ namespace MxGateway.Contracts.Proto { if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (CorrelationId != other.CorrelationId) return false; if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false; if (Hresult != other.Hresult) return false; @@ -25931,7 +25884,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode(); if (HasHresult) hash ^= Hresult.GetHashCode(); @@ -25955,10 +25907,6 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (CorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(CorrelationId); @@ -25989,10 +25937,6 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (CorrelationId.Length != 0) { output.WriteRawTag(18); output.WriteString(CorrelationId); @@ -26023,9 +25967,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (CorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); } @@ -26053,9 +25994,6 @@ namespace MxGateway.Contracts.Proto { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.CorrelationId.Length != 0) { CorrelationId = other.CorrelationId; } @@ -26096,10 +26034,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { CorrelationId = input.ReadString(); break; @@ -26145,10 +26079,6 @@ namespace MxGateway.Contracts.Proto { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; - case 10: { - SessionId = input.ReadString(); - break; - } case 18: { CorrelationId = input.ReadString(); break; @@ -26182,17 +26112,20 @@ namespace MxGateway.Contracts.Proto { } + /// + /// Request to attach to the gateway's central alarm feed (StreamAlarms). + /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] - public sealed partial class QueryActiveAlarmsRequest : pb::IMessage + public sealed partial class StreamAlarmsRequest : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { - private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new QueryActiveAlarmsRequest()); + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new StreamAlarmsRequest()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public static pb::MessageParser Parser { get { return _parser; } } + public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -26208,7 +26141,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest() { + public StreamAlarmsRequest() { OnConstruction(); } @@ -26216,8 +26149,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest(QueryActiveAlarmsRequest other) : this() { - sessionId_ = other.sessionId_; + public StreamAlarmsRequest(StreamAlarmsRequest other) : this() { clientCorrelationId_ = other.clientCorrelationId_; alarmFilterPrefix_ = other.alarmFilterPrefix_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); @@ -26225,24 +26157,12 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public QueryActiveAlarmsRequest Clone() { - return new QueryActiveAlarmsRequest(this); - } - - /// Field number for the "session_id" field. - public const int SessionIdFieldNumber = 1; - private string sessionId_ = ""; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public string SessionId { - get { return sessionId_; } - set { - sessionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } + public StreamAlarmsRequest Clone() { + return new StreamAlarmsRequest(this); } /// Field number for the "client_correlation_id" field. - public const int ClientCorrelationIdFieldNumber = 2; + public const int ClientCorrelationIdFieldNumber = 1; private string clientCorrelationId_ = ""; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -26254,11 +26174,11 @@ namespace MxGateway.Contracts.Proto { } /// Field number for the "alarm_filter_prefix" field. - public const int AlarmFilterPrefixFieldNumber = 3; + public const int AlarmFilterPrefixFieldNumber = 2; private string alarmFilterPrefix_ = ""; /// - /// Optional alarm-reference prefix used to scope a partial ConditionRefresh - /// (e.g. equipment sub-tree). Empty means full refresh. + /// Optional alarm-reference prefix scoping the feed to an equipment + /// sub-tree. Empty streams every active alarm. /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -26272,19 +26192,18 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { - return Equals(other as QueryActiveAlarmsRequest); + return Equals(other as StreamAlarmsRequest); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public bool Equals(QueryActiveAlarmsRequest other) { + public bool Equals(StreamAlarmsRequest other) { if (ReferenceEquals(other, null)) { return false; } if (ReferenceEquals(other, this)) { return true; } - if (SessionId != other.SessionId) return false; if (ClientCorrelationId != other.ClientCorrelationId) return false; if (AlarmFilterPrefix != other.AlarmFilterPrefix) return false; return Equals(_unknownFields, other._unknownFields); @@ -26294,7 +26213,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override int GetHashCode() { int hash = 1; - if (SessionId.Length != 0) hash ^= SessionId.GetHashCode(); if (ClientCorrelationId.Length != 0) hash ^= ClientCorrelationId.GetHashCode(); if (AlarmFilterPrefix.Length != 0) hash ^= AlarmFilterPrefix.GetHashCode(); if (_unknownFields != null) { @@ -26315,16 +26233,12 @@ namespace MxGateway.Contracts.Proto { #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE output.WriteRawMessage(this); #else - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { - output.WriteRawTag(18); + output.WriteRawTag(10); output.WriteString(ClientCorrelationId); } if (AlarmFilterPrefix.Length != 0) { - output.WriteRawTag(26); + output.WriteRawTag(18); output.WriteString(AlarmFilterPrefix); } if (_unknownFields != null) { @@ -26337,16 +26251,12 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (SessionId.Length != 0) { - output.WriteRawTag(10); - output.WriteString(SessionId); - } if (ClientCorrelationId.Length != 0) { - output.WriteRawTag(18); + output.WriteRawTag(10); output.WriteString(ClientCorrelationId); } if (AlarmFilterPrefix.Length != 0) { - output.WriteRawTag(26); + output.WriteRawTag(18); output.WriteString(AlarmFilterPrefix); } if (_unknownFields != null) { @@ -26359,9 +26269,6 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public int CalculateSize() { int size = 0; - if (SessionId.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeStringSize(SessionId); - } if (ClientCorrelationId.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(ClientCorrelationId); } @@ -26376,13 +26283,10 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void MergeFrom(QueryActiveAlarmsRequest other) { + public void MergeFrom(StreamAlarmsRequest other) { if (other == null) { return; } - if (other.SessionId.Length != 0) { - SessionId = other.SessionId; - } if (other.ClientCorrelationId.Length != 0) { ClientCorrelationId = other.ClientCorrelationId; } @@ -26409,14 +26313,10 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; case 10: { - SessionId = input.ReadString(); - break; - } - case 18: { ClientCorrelationId = input.ReadString(); break; } - case 26: { + case 18: { AlarmFilterPrefix = input.ReadString(); break; } @@ -26440,14 +26340,10 @@ namespace MxGateway.Contracts.Proto { _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; case 10: { - SessionId = input.ReadString(); - break; - } - case 18: { ClientCorrelationId = input.ReadString(); break; } - case 26: { + case 18: { AlarmFilterPrefix = input.ReadString(); break; } @@ -26458,6 +26354,369 @@ namespace MxGateway.Contracts.Proto { } + /// + /// One message on the StreamAlarms feed. The stream opens with one + /// `active_alarm` per currently-active alarm, then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class AlarmFeedMessage : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new AlarmFeedMessage()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[80]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage(AlarmFeedMessage other) : this() { + switch (other.PayloadCase) { + case PayloadOneofCase.ActiveAlarm: + ActiveAlarm = other.ActiveAlarm.Clone(); + break; + case PayloadOneofCase.SnapshotComplete: + SnapshotComplete = other.SnapshotComplete; + break; + case PayloadOneofCase.Transition: + Transition = other.Transition.Clone(); + break; + } + + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public AlarmFeedMessage Clone() { + return new AlarmFeedMessage(this); + } + + /// Field number for the "active_alarm" field. + public const int ActiveAlarmFieldNumber = 1; + /// + /// Part of the initial active-alarm snapshot (ConditionRefresh). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot ActiveAlarm { + get { return payloadCase_ == PayloadOneofCase.ActiveAlarm ? (global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.ActiveAlarm; + } + } + + /// Field number for the "snapshot_complete" field. + public const int SnapshotCompleteFieldNumber = 2; + /// + /// Sentinel: the initial snapshot is fully delivered and `transition` + /// messages follow. Always true when present. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool SnapshotComplete { + get { return HasSnapshotComplete ? (bool) payload_ : false; } + set { + payload_ = value; + payloadCase_ = PayloadOneofCase.SnapshotComplete; + } + } + /// Gets whether the "snapshot_complete" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasSnapshotComplete { + get { return payloadCase_ == PayloadOneofCase.SnapshotComplete; } + } + /// Clears the value of the oneof if it's currently set to "snapshot_complete" + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearSnapshotComplete() { + if (HasSnapshotComplete) { + ClearPayload(); + } + } + + /// Field number for the "transition" field. + public const int TransitionFieldNumber = 3; + /// + /// A live alarm state change (raise / acknowledge / clear). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent Transition { + get { return payloadCase_ == PayloadOneofCase.Transition ? (global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent) payload_ : null; } + set { + payload_ = value; + payloadCase_ = value == null ? PayloadOneofCase.None : PayloadOneofCase.Transition; + } + } + + private object payload_; + /// Enum of possible cases for the "payload" oneof. + public enum PayloadOneofCase { + None = 0, + ActiveAlarm = 1, + SnapshotComplete = 2, + Transition = 3, + } + private PayloadOneofCase payloadCase_ = PayloadOneofCase.None; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public PayloadOneofCase PayloadCase { + get { return payloadCase_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearPayload() { + payloadCase_ = PayloadOneofCase.None; + payload_ = null; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as AlarmFeedMessage); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(AlarmFeedMessage other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (!object.Equals(ActiveAlarm, other.ActiveAlarm)) return false; + if (SnapshotComplete != other.SnapshotComplete) return false; + if (!object.Equals(Transition, other.Transition)) return false; + if (PayloadCase != other.PayloadCase) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) hash ^= ActiveAlarm.GetHashCode(); + if (HasSnapshotComplete) hash ^= SnapshotComplete.GetHashCode(); + if (payloadCase_ == PayloadOneofCase.Transition) hash ^= Transition.GetHashCode(); + hash ^= (int) payloadCase_; + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + output.WriteRawTag(10); + output.WriteMessage(ActiveAlarm); + } + if (HasSnapshotComplete) { + output.WriteRawTag(16); + output.WriteBool(SnapshotComplete); + } + if (payloadCase_ == PayloadOneofCase.Transition) { + output.WriteRawTag(26); + output.WriteMessage(Transition); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + output.WriteRawTag(10); + output.WriteMessage(ActiveAlarm); + } + if (HasSnapshotComplete) { + output.WriteRawTag(16); + output.WriteBool(SnapshotComplete); + } + if (payloadCase_ == PayloadOneofCase.Transition) { + output.WriteRawTag(26); + output.WriteMessage(Transition); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(ActiveAlarm); + } + if (HasSnapshotComplete) { + size += 1 + 1; + } + if (payloadCase_ == PayloadOneofCase.Transition) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Transition); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(AlarmFeedMessage other) { + if (other == null) { + return; + } + switch (other.PayloadCase) { + case PayloadOneofCase.ActiveAlarm: + if (ActiveAlarm == null) { + ActiveAlarm = new global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + } + ActiveAlarm.MergeFrom(other.ActiveAlarm); + break; + case PayloadOneofCase.SnapshotComplete: + SnapshotComplete = other.SnapshotComplete; + break; + case PayloadOneofCase.Transition: + if (Transition == null) { + Transition = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + } + Transition.MergeFrom(other.Transition); + break; + } + + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot subBuilder = new global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + subBuilder.MergeFrom(ActiveAlarm); + } + input.ReadMessage(subBuilder); + ActiveAlarm = subBuilder; + break; + } + case 16: { + SnapshotComplete = input.ReadBool(); + break; + } + case 26: { + global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + if (payloadCase_ == PayloadOneofCase.Transition) { + subBuilder.MergeFrom(Transition); + } + input.ReadMessage(subBuilder); + Transition = subBuilder; + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot subBuilder = new global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot(); + if (payloadCase_ == PayloadOneofCase.ActiveAlarm) { + subBuilder.MergeFrom(ActiveAlarm); + } + input.ReadMessage(subBuilder); + ActiveAlarm = subBuilder; + break; + } + case 16: { + SnapshotComplete = input.ReadBool(); + break; + } + case 26: { + global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent subBuilder = new global::MxGateway.Contracts.Proto.OnAlarmTransitionEvent(); + if (payloadCase_ == PayloadOneofCase.Transition) { + subBuilder.MergeFrom(Transition); + } + input.ReadMessage(subBuilder); + Transition = subBuilder; + break; + } + } + } + } + #endif + + } + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class MxStatusProxy : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -26473,7 +26732,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[80]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[81]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -26904,7 +27163,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[81]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[82]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -27761,7 +28020,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[82]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[83]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -28549,7 +28808,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[83]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[84]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -28738,7 +28997,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[84]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[85]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -28927,7 +29186,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[85]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[86]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29116,7 +29375,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[86]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[87]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29305,7 +29564,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[87]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[88]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29494,7 +29753,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[88]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[89]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29681,7 +29940,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[89]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[90]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -29868,7 +30127,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[90]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[91]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -30055,7 +30314,7 @@ namespace MxGateway.Contracts.Proto { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[91]; } + get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.MessageTypes[92]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] diff --git a/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs b/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs index 55606d7..349bfac 100644 --- a/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs +++ b/src/MxGateway.Contracts/Generated/MxaccessGatewayGrpc.cs @@ -69,9 +69,9 @@ namespace MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser)); + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser)); + static readonly grpc::Marshaller __Marshaller_mxaccess_gateway_v1_AlarmFeedMessage = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AlarmFeedMessage.Parser)); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] static readonly grpc::Method __Method_OpenSession = new grpc::Method( @@ -114,12 +114,12 @@ namespace MxGateway.Contracts.Proto { __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply); [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - static readonly grpc::Method __Method_QueryActiveAlarms = new grpc::Method( + static readonly grpc::Method __Method_StreamAlarms = new grpc::Method( grpc::MethodType.ServerStreaming, __ServiceName, - "QueryActiveAlarms", - __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest, - __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot); + "StreamAlarms", + __Marshaller_mxaccess_gateway_v1_StreamAlarmsRequest, + __Marshaller_mxaccess_gateway_v1_AlarmFeedMessage); /// Service descriptor public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor @@ -161,8 +161,19 @@ namespace MxGateway.Contracts.Proto { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request received from the client. + /// Used for sending responses back to the client. + /// The context of the server-side call handler being invoked. + /// A task indicating completion of the handler. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual global::System.Threading.Tasks.Task QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) + public virtual global::System.Threading.Tasks.Task StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::IServerStreamWriter responseStream, grpc::ServerCallContext context) { throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); } @@ -286,15 +297,37 @@ namespace MxGateway.Contracts.Proto { { return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request); } + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + public virtual grpc::AsyncServerStreamingCall StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { - return QueryActiveAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + return StreamAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken)); } + /// + /// Session-less central alarm feed. The stream opens with the current + /// active-alarm snapshot (one `active_alarm` per alarm), then a single + /// `snapshot_complete`, then a `transition` for every subsequent change. + /// Served by the gateway's always-on alarm monitor; any number of clients + /// fan out from the single monitor without opening a worker session. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] - public virtual grpc::AsyncServerStreamingCall QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::CallOptions options) + public virtual grpc::AsyncServerStreamingCall StreamAlarms(global::MxGateway.Contracts.Proto.StreamAlarmsRequest request, grpc::CallOptions options) { - return CallInvoker.AsyncServerStreamingCall(__Method_QueryActiveAlarms, null, options, request); + return CallInvoker.AsyncServerStreamingCall(__Method_StreamAlarms, null, options, request); } /// Creates a new instance of client from given ClientBaseConfiguration. [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] @@ -315,7 +348,7 @@ namespace MxGateway.Contracts.Proto { .AddMethod(__Method_Invoke, serviceImpl.Invoke) .AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents) .AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm) - .AddMethod(__Method_QueryActiveAlarms, serviceImpl.QueryActiveAlarms).Build(); + .AddMethod(__Method_StreamAlarms, serviceImpl.StreamAlarms).Build(); } /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. @@ -330,7 +363,7 @@ namespace MxGateway.Contracts.Proto { serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.Invoke)); serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.StreamEvents)); serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.AcknowledgeAlarm)); - serviceBinder.AddMethod(__Method_QueryActiveAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.QueryActiveAlarms)); + serviceBinder.AddMethod(__Method_StreamAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod(serviceImpl.StreamAlarms)); } } diff --git a/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto index 9a74078..4496acd 100644 --- a/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -11,8 +11,7 @@ import "google/protobuf/timestamp.proto"; // additively only. Never renumber or repurpose an existing field number or // enum value. When a field or enum value is removed, add a `reserved` range // (and `reserved` name) covering it in the same change so a future editor -// cannot accidentally reuse the retired tag. There are no `reserved` -// declarations today because no field or enum value has ever been removed. +// cannot accidentally reuse the retired tag. // Public client API for MXAccess sessions hosted by the gateway. service MxAccessGateway { @@ -21,7 +20,12 @@ service MxAccessGateway { rpc Invoke(MxCommandRequest) returns (MxCommandReply); rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent); rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply); - rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot); + // Session-less central alarm feed. The stream opens with the current + // active-alarm snapshot (one `active_alarm` per alarm), then a single + // `snapshot_complete`, then a `transition` for every subsequent change. + // Served by the gateway's always-on alarm monitor; any number of clients + // fan out from the single monitor without opening a worker session. + rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage); } message OpenSessionRequest { @@ -785,7 +789,10 @@ enum AlarmConditionState { } message AcknowledgeAlarmRequest { - string session_id = 1; + // Retired: acknowledgement is session-less — it routes to the gateway's + // central alarm monitor, not a client worker session. + reserved 1; + reserved "session_id"; string client_correlation_id = 2; // Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference. string alarm_full_reference = 3; @@ -797,7 +804,9 @@ message AcknowledgeAlarmRequest { } message AcknowledgeAlarmReply { - string session_id = 1; + // Retired: see AcknowledgeAlarmRequest — acknowledgement is session-less. + reserved 1; + reserved "session_id"; string correlation_id = 2; ProtocolStatus protocol_status = 3; // Native ack return code echoed from the worker. The worker carries the @@ -816,12 +825,27 @@ message AcknowledgeAlarmReply { string diagnostic_message = 6; } -message QueryActiveAlarmsRequest { - string session_id = 1; - string client_correlation_id = 2; - // Optional alarm-reference prefix used to scope a partial ConditionRefresh - // (e.g. equipment sub-tree). Empty means full refresh. - string alarm_filter_prefix = 3; +// Request to attach to the gateway's central alarm feed (StreamAlarms). +message StreamAlarmsRequest { + string client_correlation_id = 1; + // Optional alarm-reference prefix scoping the feed to an equipment + // sub-tree. Empty streams every active alarm. + string alarm_filter_prefix = 2; +} + +// One message on the StreamAlarms feed. The stream opens with one +// `active_alarm` per currently-active alarm, then a single +// `snapshot_complete`, then a `transition` for every subsequent change. +message AlarmFeedMessage { + oneof payload { + // Part of the initial active-alarm snapshot (ConditionRefresh). + ActiveAlarmSnapshot active_alarm = 1; + // Sentinel: the initial snapshot is fully delivered and `transition` + // messages follow. Always true when present. + bool snapshot_complete = 2; + // A live alarm state change (raise / acknowledge / clear). + OnAlarmTransitionEvent transition = 3; + } } message MxStatusProxy { diff --git a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index b0b1311..686dd0a 100644 --- a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -1084,7 +1084,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) mapper, eventStreamService, _metrics, - _loggerFactory.CreateLogger()); + _loggerFactory.CreateLogger(), + new MxGateway.Server.Alarms.GatewayAlarmMonitor( + sessionManager, + options, + _loggerFactory.CreateLogger())); } /// diff --git a/src/MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs b/src/MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..b88b5c6 --- /dev/null +++ b/src/MxGateway.Server/Alarms/AlarmsServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +namespace MxGateway.Server.Alarms; + +/// Service-collection wiring for the gateway's central alarm monitor. +public static class AlarmsServiceCollectionExtensions +{ + /// + /// Registers the always-on as both + /// the singleton and a hosted + /// service, so it starts with the gateway host and is shared by the + /// gRPC alarm surface and the dashboard. + /// + /// Service collection to register services in. + /// The service collection for chaining. + public static IServiceCollection AddGatewayAlarms(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + + return services; + } +} diff --git a/src/MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/MxGateway.Server/Alarms/GatewayAlarmMonitor.cs new file mode 100644 index 0000000..fa334b4 --- /dev/null +++ b/src/MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -0,0 +1,693 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Options; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Configuration; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Alarms; + +/// +/// The gateway's always-on alarm monitor and broker. It owns one +/// gateway-managed worker session dedicated to alarms, keeps an in-process +/// cache of the active-alarm set fed by that session's transition events +/// (reconciled periodically against the worker's snapshot), and fans the +/// feed out to any number of subscribers. +/// The session is re-opened transparently if the worker faults. +/// +public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmService +{ + private const string MonitorClientName = "gateway-alarm-monitor"; + private const string BackendName = "Galaxy"; + private const int SubscriberQueueCapacity = 2048; + private static readonly TimeSpan RestartBackoff = TimeSpan.FromSeconds(5); + private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2); + + private readonly ISessionManager _sessionManager; + private readonly AlarmsOptions _options; + private readonly ILogger _logger; + + private readonly object _sync = new(); + private readonly Dictionary _alarms = new(StringComparer.Ordinal); + private readonly List _subscribers = []; + + private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; + private volatile string? _lastError; + private GatewaySession? _session; + + /// Initializes the gateway alarm monitor. + /// Gateway session manager. + /// Gateway options carrying the alarm configuration. + /// Diagnostic logger. + public GatewayAlarmMonitor( + ISessionManager sessionManager, + IOptions options, + ILogger logger) + { + _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public GatewayAlarmMonitorState State => _state; + + /// + public string? LastError => _lastError; + + /// + public int? WorkerProcessId + { + get { lock (_sync) { return _session?.WorkerProcessId; } } + } + + /// + public IReadOnlyList CurrentAlarms + { + get + { + lock (_sync) + { + return _alarms.Values.Select(alarm => alarm.Clone()).ToArray(); + } + } + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + { + _state = GatewayAlarmMonitorState.Disabled; + _logger.LogInformation("Gateway alarm monitor disabled (MxGateway:Alarms:Enabled is false)."); + return; + } + + string subscription = ResolveSubscription(); + if (string.IsNullOrWhiteSpace(subscription)) + { + _state = GatewayAlarmMonitorState.Faulted; + _lastError = "MxGateway:Alarms is enabled but no SubscriptionExpression / DefaultArea is configured."; + _logger.LogError("{Diagnostic}", _lastError); + return; + } + + // Brief grace so worker-process launching and startup orphan cleanup + // settle before the monitor opens its own session. + try + { + await Task.Delay(StartupGrace, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunMonitorAsync(subscription, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + _state = GatewayAlarmMonitorState.Faulted; + _lastError = exception.Message; + _logger.LogWarning( + exception, + "Gateway alarm monitor lifecycle faulted; restarting in {Backoff}.", + RestartBackoff); + try + { + await Task.Delay(RestartBackoff, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + _state = GatewayAlarmMonitorState.Disabled; + } + + // One monitoring lifecycle: open a session, subscribe alarms, reconcile, + // then consume transition events until the session ends or is cancelled. + private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken) + { + _state = GatewayAlarmMonitorState.Starting; + GatewaySession session = await _sessionManager.OpenSessionAsync( + new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), + MonitorClientName, + stoppingToken) + .ConfigureAwait(false); + lock (_sync) { _session = session; } + + try + { + await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false); + await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false); + + _state = GatewayAlarmMonitorState.Monitoring; + _lastError = null; + _logger.LogInformation( + "Gateway alarm monitor active on {Subscription} (session {SessionId}, worker pid {WorkerPid}).", + subscription, + session.SessionId, + session.WorkerProcessId); + + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + Task reconcileLoop = ReconcileLoopAsync(session.SessionId, linked.Token); + try + { + await foreach (WorkerEvent workerEvent in _sessionManager + .ReadEventsAsync(session.SessionId, linked.Token) + .ConfigureAwait(false)) + { + MxEvent? mxEvent = workerEvent.Event; + if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition } + && mxEvent.OnAlarmTransition is not null) + { + ApplyTransition(mxEvent.OnAlarmTransition); + } + } + } + finally + { + await linked.CancelAsync().ConfigureAwait(false); + try + { + await reconcileLoop.ConfigureAwait(false); + } + catch + { + // Reconcile-loop teardown errors are not actionable here. + } + } + + // The event stream ended without cancellation — the worker session + // closed or faulted. Surface it so the supervisor loop restarts. + throw new InvalidOperationException("Alarm monitor worker event stream ended."); + } + finally + { + lock (_sync) { _session = null; } + ClearCache(); + try + { + await _sessionManager.CloseSessionAsync(session.SessionId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Closing alarm monitor session {SessionId} failed.", session.SessionId); + } + } + } + + private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken) + { + WorkerCommandReply reply = await _sessionManager.InvokeAsync( + sessionId, + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.SubscribeAlarms, + SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code; + if (code != ProtocolStatusCode.Ok) + { + string diagnostic = reply.Reply?.DiagnosticMessage + ?? reply.Reply?.ProtocolStatus?.Message + ?? $"status {code}"; + throw new InvalidOperationException($"Worker rejected SubscribeAlarms: {diagnostic}"); + } + } + + private async Task ReconcileLoopAsync(string sessionId, CancellationToken cancellationToken) + { + try + { + int seconds = Math.Max(5, _options.ReconcileIntervalSeconds); + using PeriodicTimer timer = new(TimeSpan.FromSeconds(seconds)); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Alarm reconcile pass failed; keeping the current cache."); + } + } + } + catch (OperationCanceledException) + { + } + } + + private async Task ReconcileAsync(string sessionId, CancellationToken cancellationToken) + { + WorkerCommandReply reply = await _sessionManager.InvokeAsync( + sessionId, + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.QueryActiveAlarms, + QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand { AlarmFilterPrefix = string.Empty }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + if (reply.Reply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) + { + return; + } + + QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms; + if (payload is not null) + { + ApplyReconcile(payload.Snapshots); + } + } + + // Applies a live transition to the cache and broadcasts it to subscribers. + private void ApplyTransition(OnAlarmTransitionEvent transition) + { + string reference = transition.AlarmFullReference ?? string.Empty; + if (reference.Length == 0) + { + return; + } + + lock (_sync) + { + if (transition.TransitionKind == AlarmTransitionKind.Clear) + { + _alarms.Remove(reference); + } + else + { + _alarms[reference] = SnapshotFromTransition(transition); + } + + Broadcast(new AlarmFeedMessage { Transition = transition }, reference); + } + } + + // Replaces the cache with the worker's authoritative snapshot, broadcasting + // a synthetic transition for any alarm the live stream missed. + private void ApplyReconcile(IEnumerable snapshots) + { + Dictionary next = new(StringComparer.Ordinal); + foreach (ActiveAlarmSnapshot snapshot in snapshots) + { + if (!string.IsNullOrEmpty(snapshot.AlarmFullReference)) + { + next[snapshot.AlarmFullReference] = snapshot; + } + } + + lock (_sync) + { + foreach (KeyValuePair existing in _alarms) + { + if (!next.ContainsKey(existing.Key)) + { + Broadcast( + new AlarmFeedMessage { Transition = TransitionFromSnapshot(existing.Value, AlarmTransitionKind.Clear) }, + existing.Key); + } + } + + foreach (KeyValuePair incoming in next) + { + if (!_alarms.ContainsKey(incoming.Key)) + { + Broadcast( + new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) }, + incoming.Key); + } + } + + _alarms.Clear(); + foreach (KeyValuePair incoming in next) + { + _alarms[incoming.Key] = incoming.Value; + } + } + } + + // Caller holds _sync. Pushes a feed message to every matching subscriber; + // a subscriber that has fallen behind is completed with an error and dropped. + private void Broadcast(AlarmFeedMessage message, string reference) + { + for (int index = _subscribers.Count - 1; index >= 0; index--) + { + Subscriber subscriber = _subscribers[index]; + if (!subscriber.Matches(reference)) + { + continue; + } + + if (!subscriber.Channel.Writer.TryWrite(message)) + { + subscriber.Channel.Writer.TryComplete(new InvalidOperationException( + "Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot.")); + _subscribers.RemoveAt(index); + } + } + } + + private void ClearCache() + { + lock (_sync) + { + _alarms.Clear(); + } + } + + /// + public async IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + string prefix = alarmFilterPrefix ?? string.Empty; + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions(SubscriberQueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + Subscriber subscriber = new(channel, prefix); + + ActiveAlarmSnapshot[] snapshot; + lock (_sync) + { + // Register before snapshotting under the same lock so no transition + // can slip between the snapshot and the live stream. + _subscribers.Add(subscriber); + snapshot = _alarms.Values + .Where(alarm => prefix.Length == 0 + || alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal)) + .Select(alarm => alarm.Clone()) + .ToArray(); + } + + try + { + foreach (ActiveAlarmSnapshot alarm in snapshot) + { + yield return new AlarmFeedMessage { ActiveAlarm = alarm }; + } + + yield return new AlarmFeedMessage { SnapshotComplete = true }; + + await foreach (AlarmFeedMessage message in channel.Reader + .ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) + { + yield return message; + } + } + finally + { + lock (_sync) { _subscribers.Remove(subscriber); } + channel.Writer.TryComplete(); + } + } + + /// + public async Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string? sessionId; + lock (_sync) { sessionId = _session?.SessionId; } + if (sessionId is null || _state != GatewayAlarmMonitorState.Monitoring) + { + return new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.WorkerUnavailable, + Message = "Gateway alarm monitor is not currently active.", + }, + DiagnosticMessage = _lastError ?? "Alarm monitor is not running.", + }; + } + + MxCommand? command = BuildAcknowledgeCommand(request, out string? parseError); + if (command is null) + { + return new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.InvalidRequest, + Message = parseError ?? "Invalid acknowledge request.", + }, + DiagnosticMessage = parseError ?? "Invalid acknowledge request.", + }; + } + + WorkerCommandReply workerReply = await _sessionManager + .InvokeAsync(sessionId, new WorkerCommand { Command = command }, cancellationToken) + .ConfigureAwait(false); + + MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply + { + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.ProtocolViolation, + Message = "Worker reply did not include an MxCommandReply.", + }, + }; + + AcknowledgeAlarmReply reply = new() + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = mxReply.ProtocolStatus ?? new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty, + }; + if (mxReply.HasHresult) + { + reply.Hresult = mxReply.Hresult; + } + + return reply; + } + + private string ResolveSubscription() + { + if (!string.IsNullOrWhiteSpace(_options.SubscriptionExpression)) + { + return _options.SubscriptionExpression; + } + + if (!string.IsNullOrWhiteSpace(_options.DefaultArea)) + { + return $@"\\{Environment.MachineName}\Galaxy!{_options.DefaultArea}"; + } + + return string.Empty; + } + + private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError) + { + parseError = null; + if (string.IsNullOrWhiteSpace(request.AlarmFullReference)) + { + parseError = "alarm_full_reference is required."; + return null; + } + + string comment = request.Comment ?? string.Empty; + string operatorUser = request.OperatorUser ?? string.Empty; + + if (Guid.TryParse(request.AlarmFullReference, out Guid guid)) + { + return new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = guid.ToString(), + Comment = comment, + OperatorUser = operatorUser, + OperatorNode = string.Empty, + OperatorDomain = string.Empty, + OperatorFullName = string.Empty, + }, + }; + } + + if (TryParseAlarmReference(request.AlarmFullReference, out string provider, out string group, out string alarm)) + { + return new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = alarm, + ProviderName = provider, + GroupName = group, + Comment = comment, + OperatorUser = operatorUser, + OperatorNode = string.Empty, + OperatorDomain = string.Empty, + OperatorFullName = string.Empty, + }, + }; + } + + parseError = "alarm_full_reference must be a canonical GUID or 'Provider!Group.Tag' format."; + return null; + } + + /// + /// Parses an alarm reference of the form Provider!Group.Tag: the + /// first ! splits provider from Group.Tag; the first + /// . after the ! splits group from tag. + /// + /// The full alarm reference. + /// The parsed provider. + /// The parsed group/area. + /// The parsed tag/alarm name. + /// true on a well-formed reference; otherwise false. + public static bool TryParseAlarmReference( + string? reference, + out string providerName, + out string groupName, + out string alarmName) + { + providerName = string.Empty; + groupName = string.Empty; + alarmName = string.Empty; + if (string.IsNullOrWhiteSpace(reference)) + { + return false; + } + + int bang = reference!.IndexOf('!', StringComparison.Ordinal); + if (bang <= 0 || bang == reference.Length - 1) + { + return false; + } + + string left = reference[..bang]; + string right = reference[(bang + 1)..]; + int dot = right.IndexOf('.', StringComparison.Ordinal); + if (dot <= 0 || dot == right.Length - 1) + { + return false; + } + + providerName = left; + groupName = right[..dot]; + alarmName = right[(dot + 1)..]; + return true; + } + + private static ActiveAlarmSnapshot SnapshotFromTransition(OnAlarmTransitionEvent transition) + { + ActiveAlarmSnapshot snapshot = new() + { + AlarmFullReference = transition.AlarmFullReference, + SourceObjectReference = transition.SourceObjectReference, + AlarmTypeName = transition.AlarmTypeName, + Severity = transition.Severity, + CurrentState = transition.TransitionKind == AlarmTransitionKind.Acknowledge + ? AlarmConditionState.ActiveAcked + : AlarmConditionState.Active, + Category = transition.Category, + Description = transition.Description, + OperatorUser = transition.OperatorUser, + OperatorComment = transition.OperatorComment, + }; + if (transition.OriginalRaiseTimestamp is not null) + { + snapshot.OriginalRaiseTimestamp = transition.OriginalRaiseTimestamp; + } + if (transition.TransitionTimestamp is not null) + { + snapshot.LastTransitionTimestamp = transition.TransitionTimestamp; + } + if (transition.CurrentValue is not null) + { + snapshot.CurrentValue = transition.CurrentValue; + } + if (transition.LimitValue is not null) + { + snapshot.LimitValue = transition.LimitValue; + } + + return snapshot; + } + + private static OnAlarmTransitionEvent TransitionFromSnapshot( + ActiveAlarmSnapshot snapshot, + AlarmTransitionKind kind) + { + OnAlarmTransitionEvent transition = new() + { + AlarmFullReference = snapshot.AlarmFullReference, + SourceObjectReference = snapshot.SourceObjectReference, + AlarmTypeName = snapshot.AlarmTypeName, + TransitionKind = kind, + Severity = snapshot.Severity, + Category = snapshot.Category, + Description = snapshot.Description, + OperatorUser = snapshot.OperatorUser, + OperatorComment = snapshot.OperatorComment, + }; + if (snapshot.OriginalRaiseTimestamp is not null) + { + transition.OriginalRaiseTimestamp = snapshot.OriginalRaiseTimestamp; + } + if (snapshot.LastTransitionTimestamp is not null) + { + transition.TransitionTimestamp = snapshot.LastTransitionTimestamp; + } + if (snapshot.CurrentValue is not null) + { + transition.CurrentValue = snapshot.CurrentValue; + } + if (snapshot.LimitValue is not null) + { + transition.LimitValue = snapshot.LimitValue; + } + + return transition; + } + + private sealed class Subscriber(Channel channel, string prefix) + { + public Channel Channel { get; } = channel; + + public bool Matches(string reference) + { + return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal); + } + } +} diff --git a/src/MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/MxGateway.Server/Alarms/IGatewayAlarmService.cs new file mode 100644 index 0000000..281d61b --- /dev/null +++ b/src/MxGateway.Server/Alarms/IGatewayAlarmService.cs @@ -0,0 +1,63 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Server.Alarms; + +/// Lifecycle state of the gateway's central alarm monitor. +public enum GatewayAlarmMonitorState +{ + /// Alarm monitoring is switched off (MxGateway:Alarms:Enabled is false). + Disabled, + + /// The monitor is opening or re-opening its worker session. + Starting, + + /// The monitor is connected and tracking the active-alarm set. + Monitoring, + + /// The monitor's last lifecycle attempt failed; a restart is pending. + Faulted, +} + +/// +/// The gateway's always-on alarm broker. A single gateway-owned worker +/// session monitors the AVEVA alarm provider; this service caches the +/// current active-alarm set and fans it out to any number of clients — +/// no client needs to open its own worker session to see alarms. +/// +public interface IGatewayAlarmService +{ + /// Current monitor lifecycle state. + GatewayAlarmMonitorState State { get; } + + /// Diagnostic message from the most recent fault, or null. + string? LastError { get; } + + /// Process id of the worker backing the monitor, when one is attached. + int? WorkerProcessId { get; } + + /// A point-in-time copy of the current active-alarm set. + IReadOnlyList CurrentAlarms { get; } + + /// + /// Attaches to the central alarm feed. The returned stream yields one + /// per currently-active alarm, then a + /// single snapshot_complete sentinel, then a transition + /// for every subsequent change. + /// + /// Optional alarm-reference prefix scoping the feed. + /// Token that ends the subscription. + IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + CancellationToken cancellationToken); + + /// + /// Acknowledges an alarm through the monitor's worker session. Never + /// throws — transport and monitor-state failures surface in the + /// reply's . + /// + /// The acknowledge request. + /// Token to cancel the call. + Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Configuration/AlarmsOptions.cs b/src/MxGateway.Server/Configuration/AlarmsOptions.cs index 2722fac..dd47d78 100644 --- a/src/MxGateway.Server/Configuration/AlarmsOptions.cs +++ b/src/MxGateway.Server/Configuration/AlarmsOptions.cs @@ -1,32 +1,32 @@ namespace MxGateway.Server.Configuration; /// -/// Per-gateway alarm-subsystem configuration. Drives the auto-subscribe -/// hook in : when -/// is true and a session reaches Ready, the -/// manager issues a SubscribeAlarmsCommand to the worker with -/// the configured . +/// Configuration for the gateway's always-on central alarm monitor +/// (). When +/// is true the gateway opens one gateway-owned worker session dedicated to +/// alarms, caches the active-alarm set, and fans it out to every client +/// through the StreamAlarms RPC — no client opens its own session +/// to see alarms. /// /// -/// Defaults preserve current behaviour (alarms disabled). Operators -/// opt in by setting MxGateway:Alarms:Enabled = true and -/// supplying a canonical -/// \\<machine>\Galaxy!<area> subscription -/// expression. The literal "Galaxy" provider is correct regardless of -/// the configured Galaxy database name (the wnwrap consumer doesn't -/// accept the database name as the provider). +/// Defaults preserve current behaviour (alarm monitoring disabled). +/// Operators opt in by setting MxGateway:Alarms:Enabled = true and +/// supplying a canonical \\<machine>\Galaxy!<area> +/// subscription expression. The literal "Galaxy" provider is correct +/// regardless of the configured Galaxy database name (the wnwrap consumer +/// does not accept the database name as the provider). /// public sealed class AlarmsOptions { - /// Gate the auto-subscribe hook on session open. Default false. + /// Gate the gateway's always-on central alarm monitor. Default false. public bool Enabled { get; init; } /// - /// AVEVA alarm-subscription expression. When empty and - /// is true, the gateway falls back to - /// \\$(MachineName)\Galaxy!$(DefaultArea) if - /// is set; otherwise the session open - /// fails with a configuration diagnostic. + /// AVEVA alarm-subscription expression the monitor subscribes on + /// startup. When empty and is true, the gateway + /// falls back to \\$(MachineName)\Galaxy!$(DefaultArea) if + /// is set; otherwise the monitor faults with + /// a configuration diagnostic. /// public string SubscriptionExpression { get; init; } = string.Empty; @@ -39,10 +39,10 @@ public sealed class AlarmsOptions public string DefaultArea { get; init; } = string.Empty; /// - /// If true, an auto-subscribe failure faults the session. If false - /// (default), the failure is logged and the session remains Ready — - /// alarm-side commands return "not subscribed" but data subscriptions - /// work normally. + /// How often the monitor reconciles its in-process alarm cache against + /// the worker's authoritative active-alarm snapshot, catching any + /// transitions the live poll-and-diff feed missed. Default 30 seconds; + /// the monitor floors it at 5 seconds. /// - public bool RequireSubscribeOnOpen { get; init; } + public int ReconcileIntervalSeconds { get; init; } = 30; } diff --git a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 3421607..77a0288 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -236,11 +236,10 @@ public sealed class GatewayOptionsValidator : IValidateOptions return; } - // When the alarm auto-subscribe hook is enabled, the gateway needs either a - // canonical SubscriptionExpression or a DefaultArea to compose one from. Both - // empty is the configuration mistake SessionManager.TryAutoSubscribeAlarmsAsync - // currently surfaces per-session — pulling it up to startup validation makes - // the misconfiguration fail-fast at boot, in line with every other section. + // When the central alarm monitor is enabled, it needs either a canonical + // SubscriptionExpression or a DefaultArea to compose one from. Validating + // it at startup makes the misconfiguration fail-fast at boot, in line + // with every other section. if (string.IsNullOrWhiteSpace(options.SubscriptionExpression) && string.IsNullOrWhiteSpace(options.DefaultArea)) { diff --git a/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs index 6efe838..a43e773 100644 --- a/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs +++ b/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -1,4 +1,5 @@ using MxGateway.Contracts.Proto; +using MxGateway.Server.Alarms; using MxGateway.Server.Sessions; namespace MxGateway.Server.Dashboard; @@ -17,7 +18,7 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); private readonly ISessionManager _sessionManager; - private readonly IAlarmRpcDispatcher _alarmDispatcher; + private readonly IGatewayAlarmService _alarmService; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); private readonly HashSet _subscribed = new(StringComparer.OrdinalIgnoreCase); @@ -28,15 +29,15 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync /// Initializes the live-data service. /// Gateway session manager. - /// Active-alarm query dispatcher. + /// Gateway central alarm service. /// Diagnostic logger. public DashboardLiveDataService( ISessionManager sessionManager, - IAlarmRpcDispatcher alarmDispatcher, + IGatewayAlarmService alarmService, ILogger logger) { _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); - _alarmDispatcher = alarmDispatcher ?? throw new ArgumentNullException(nameof(alarmDispatcher)); + _alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -90,39 +91,20 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync } /// - public async Task QueryAlarmsAsync(CancellationToken cancellationToken) + public Task QueryAlarmsAsync(CancellationToken cancellationToken) { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - (GatewaySession session, _) = await EnsureReadyAsync(cancellationToken).ConfigureAwait(false); + // Alarms come from the gateway's always-on central monitor; the + // dashboard reads its in-process cache directly — no session needed. + DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms + .Select(DashboardActiveAlarm.FromSnapshot) + .ToArray(); - QueryActiveAlarmsRequest request = new() - { - SessionId = session.SessionId, - ClientCorrelationId = Guid.NewGuid().ToString("N"), - }; + string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring + or GatewayAlarmMonitorState.Disabled + ? null + : _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}."; - List alarms = []; - await foreach (ActiveAlarmSnapshot snapshot in _alarmDispatcher - .QueryActiveAlarmsAsync(request, cancellationToken) - .ConfigureAwait(false)) - { - alarms.Add(DashboardActiveAlarm.FromSnapshot(snapshot)); - } - - return new DashboardAlarmQueryResult(alarms, null, session.WorkerProcessId); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - InvalidateSession(); - _logger.LogWarning(exception, "Dashboard alarm query failed; the dashboard session will be re-opened."); - return new DashboardAlarmQueryResult([], exception.Message, null); - } - finally - { - _gate.Release(); - } + return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId)); } // Returns a Ready session + its Register server handle, opening a fresh diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index bb19b90..c450065 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting.StaticWebAssets; using MxGateway.Contracts; +using MxGateway.Server.Alarms; using MxGateway.Server.Configuration; using MxGateway.Server.Dashboard; using MxGateway.Server.Diagnostics; @@ -64,6 +65,7 @@ public static class GatewayApplication builder.Services.AddSingleton(); builder.Services.AddWorkerProcessLauncher(); builder.Services.AddGatewaySessions(); + builder.Services.AddGatewayAlarms(); builder.Services.AddGatewayDashboard(); builder.Services.AddGalaxyRepository(); diff --git a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs index c59bde7..e143881 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -3,6 +3,7 @@ using Grpc.Core; using Google.Protobuf.WellKnownTypes; using MxGateway.Contracts; using MxGateway.Contracts.Proto; +using MxGateway.Server.Alarms; using MxGateway.Server.Metrics; using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; @@ -21,9 +22,8 @@ public sealed class MxAccessGatewayService( IEventStreamService eventStreamService, GatewayMetrics metrics, ILogger logger, - IAlarmRpcDispatcher? alarmRpcDispatcher = null) : MxAccessGateway.MxAccessGatewayBase + IGatewayAlarmService alarmService) : MxAccessGateway.MxAccessGatewayBase { - private readonly IAlarmRpcDispatcher alarmRpcDispatcher = alarmRpcDispatcher ?? new NotWiredAlarmRpcDispatcher(); /// public override async Task OpenSession( OpenSessionRequest request, @@ -163,14 +163,13 @@ public sealed class MxAccessGatewayService( /// /// - /// Surfaces the public AcknowledgeAlarm RPC. The gateway validates the request, - /// resolves the session, and delegates to the registered - /// . DI binds the production - /// , which routes - /// the ack through the worker pipe IPC: an alarm_full_reference that parses - /// as a canonical GUID forwards to AcknowledgeAlarmCommand; a - /// Provider!Group.Tag reference forwards to AcknowledgeAlarmByNameCommand; - /// anything else returns an InvalidRequest diagnostic. + /// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is + /// session-less: the gateway routes it through the always-on + /// monitor session. An + /// alarm_full_reference that parses as a canonical GUID forwards + /// to AcknowledgeAlarmCommand; a Provider!Group.Tag + /// reference forwards to AcknowledgeAlarmByNameCommand; anything + /// else returns an InvalidRequest diagnostic in the reply. /// public override async Task AcknowledgeAlarm( AcknowledgeAlarmRequest request, @@ -179,25 +178,12 @@ public sealed class MxAccessGatewayService( try { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.SessionId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required.")); - } if (string.IsNullOrEmpty(request.AlarmFullReference)) { throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required.")); } - // Validate the session exists. Throws SessionManagerException → mapped to - // gRPC NotFound by the caller's MapException. - _ = ResolveSession(request.SessionId); - - // Delegate to the registered alarm dispatcher. DI binds the production - // WorkerAlarmRpcDispatcher, which routes the ack over the worker IPC by - // GUID (AcknowledgeAlarmCommand) or by Provider!Group.Tag reference - // (AcknowledgeAlarmByNameCommand). NotWiredAlarmRpcDispatcher is only the - // null fallback used when no dispatcher is registered. - return await alarmRpcDispatcher.AcknowledgeAsync(request, context.CancellationToken) + return await alarmService.AcknowledgeAsync(request, context.CancellationToken) .ConfigureAwait(false); } catch (Exception exception) when (exception is not RpcException) @@ -208,38 +194,27 @@ public sealed class MxAccessGatewayService( /// /// - /// Surfaces the public QueryActiveAlarms RPC. The gateway validates the request, - /// resolves the session, and delegates to the registered - /// . DI binds the production - /// , which issues a - /// QueryActiveAlarmsCommand over the worker pipe IPC and streams each - /// ActiveAlarmSnapshot from the worker reply. + /// Surfaces the public StreamAlarms RPC — the session-less central + /// alarm feed. The stream opens with one active_alarm per + /// currently-active alarm, then a single snapshot_complete, then + /// a transition for every subsequent change. Served by the + /// gateway's always-on monitor; any + /// number of clients fan out from the single monitor. /// - public override async Task QueryActiveAlarms( - QueryActiveAlarmsRequest request, - IServerStreamWriter responseStream, + public override async Task StreamAlarms( + StreamAlarmsRequest request, + IServerStreamWriter responseStream, ServerCallContext context) { try { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrEmpty(request.SessionId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "session_id is required.")); - } - _ = ResolveSession(request.SessionId); - - // Delegate to the registered alarm dispatcher. DI binds the production - // WorkerAlarmRpcDispatcher, which issues a QueryActiveAlarmsCommand over the - // worker IPC and streams each ActiveAlarmSnapshot from the worker reply. - // NotWiredAlarmRpcDispatcher is only the null fallback used when no - // dispatcher is registered. - await foreach (ActiveAlarmSnapshot snapshot in alarmRpcDispatcher - .QueryActiveAlarmsAsync(request, context.CancellationToken) + await foreach (AlarmFeedMessage message in alarmService + .StreamAsync(request.AlarmFilterPrefix, context.CancellationToken) .WithCancellation(context.CancellationToken) .ConfigureAwait(false)) { - await responseStream.WriteAsync(snapshot).ConfigureAwait(false); + await responseStream.WriteAsync(message).ConfigureAwait(false); } } catch (Exception exception) when (exception is not RpcException) diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs index bd9428a..d134065 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs +++ b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -19,7 +19,7 @@ public sealed class GatewayGrpcScopeResolver StreamEventsRequest => GatewayScopes.EventsRead, MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite, - QueryActiveAlarmsRequest => GatewayScopes.EventsRead, + StreamAlarmsRequest => GatewayScopes.EventsRead, TestConnectionRequest or GetLastDeployTimeRequest or DiscoverHierarchyRequest or diff --git a/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs deleted file mode 100644 index 41170f3..0000000 --- a/src/MxGateway.Server/Sessions/IAlarmRpcDispatcher.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; - -namespace MxGateway.Server.Sessions; - -/// -/// Gateway-side dispatcher seam for the alarm-RPC surface. Bridges the -/// public AcknowledgeAlarm + QueryActiveAlarms gRPC handlers -/// to the worker process that hosts IMxAccessAlarmConsumer. -/// -/// -/// -/// DI binds the production by -/// default; it routes calls through the existing worker-pipe IPC. -/// NotWiredAlarmRpcDispatcher is only the null fallback used -/// when no dispatcher is registered (DI omission / standalone tests). -/// Other tests inject a fake to exercise the gateway handler shape -/// without spinning up a worker process. -/// -/// -/// The dispatcher is session-scoped: every call resolves the -/// session and forwards to that session's worker. The handler -/// constructs the / -/// stream from the dispatcher's -/// output without further translation. -/// -/// -public interface IAlarmRpcDispatcher -{ - /// Forward an Acknowledge to the worker that owns the session. - Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken); - - /// Walk active alarms on the worker that owns the session. - IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - CancellationToken cancellationToken); -} diff --git a/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs deleted file mode 100644 index 2e5306d..0000000 --- a/src/MxGateway.Server/Sessions/NotWiredAlarmRpcDispatcher.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; - -namespace MxGateway.Server.Sessions; - -/// -/// Null fallback used when no dispatcher -/// is registered in the DI container (DI omission or standalone tests). -/// Acknowledges with a structured "alarm dispatcher not registered" -/// diagnostic and yields an empty active-alarm stream. -/// -/// -/// -/// Production wires as the -/// default via -/// SessionServiceCollectionExtensions.AddGatewaySessions, so -/// clients that hit this fallback are running against an -/// intentionally minimal service composition rather than the full -/// gateway. -/// -/// -public sealed class NotWiredAlarmRpcDispatcher : IAlarmRpcDispatcher -{ - /// - public Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken) - { - return Task.FromResult(new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = MxAccessGrpcMapper.Ok("AcknowledgeAlarm accepted; alarm dispatcher is not registered."), - DiagnosticMessage = "Alarm dispatcher is not registered.", - }); - } - - /// -#pragma warning disable CS1998 // Async method lacks 'await' operators — empty stream is intentional. - public async IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - yield break; - } -#pragma warning restore CS1998 -} diff --git a/src/MxGateway.Server/Sessions/SessionManager.cs b/src/MxGateway.Server/Sessions/SessionManager.cs index 488c2f8..4a0ee21 100644 --- a/src/MxGateway.Server/Sessions/SessionManager.cs +++ b/src/MxGateway.Server/Sessions/SessionManager.cs @@ -90,8 +90,6 @@ public sealed class SessionManager : ISessionManager _metrics.SessionOpened(); sessionOpenedRecorded = true; - await TryAutoSubscribeAlarmsAsync(session, cancellationToken).ConfigureAwait(false); - return session; } catch (Exception exception) @@ -410,100 +408,4 @@ public sealed class SessionManager : ISessionManager return Convert.ToBase64String(bytes); } - /// - /// If Alarms.Enabled is configured, issue a - /// SubscribeAlarmsCommand on the freshly-Ready session so the - /// worker's wnwrap consumer starts polling. Failure handling is - /// governed by Alarms.RequireSubscribeOnOpen: - /// - /// true — propagate the failure to fault the session. - /// false (default) — log a warning and let the session continue serving data subscriptions. - /// - /// - private async Task TryAutoSubscribeAlarmsAsync( - GatewaySession session, - CancellationToken cancellationToken) - { - AlarmsOptions alarms = _options.Alarms; - if (!alarms.Enabled) return; - - string subscription = ResolveAlarmSubscription(alarms); - if (string.IsNullOrWhiteSpace(subscription)) - { - const string diagnostic = - "Alarms.Enabled is true but no SubscriptionExpression / DefaultArea is configured."; - if (alarms.RequireSubscribeOnOpen) - { - throw new SessionManagerException( - SessionManagerErrorCode.OpenFailed, diagnostic); - } - _logger.LogWarning( - "Auto-subscribe skipped for session {SessionId}: {Diagnostic}", - session.SessionId, diagnostic); - return; - } - - WorkerCommand command = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.SubscribeAlarms, - SubscribeAlarms = new SubscribeAlarmsCommand - { - SubscriptionExpression = subscription, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()), - }; - - try - { - WorkerCommandReply reply = await session.InvokeAsync(command, cancellationToken) - .ConfigureAwait(false); - ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code; - if (code != ProtocolStatusCode.Ok) - { - string diagnostic = reply.Reply?.DiagnosticMessage - ?? reply.Reply?.ProtocolStatus?.Message - ?? "Worker rejected SubscribeAlarms."; - if (alarms.RequireSubscribeOnOpen) - { - throw new SessionManagerException( - SessionManagerErrorCode.OpenFailed, - $"Auto-subscribe failed for session {session.SessionId}: {diagnostic}"); - } - _logger.LogWarning( - "Auto-subscribe failed for session {SessionId} (status {StatusCode}): {Diagnostic}", - session.SessionId, code, diagnostic); - return; - } - _logger.LogInformation( - "Alarm auto-subscribe succeeded for session {SessionId} on {Subscription}.", - session.SessionId, subscription); - } - catch (SessionManagerException) - { - throw; - } - catch (Exception ex) when (!alarms.RequireSubscribeOnOpen) - { - _logger.LogWarning( - ex, - "Auto-subscribe threw for session {SessionId} on {Subscription}; alarm path remains inactive.", - session.SessionId, subscription); - } - } - - private static string ResolveAlarmSubscription(AlarmsOptions alarms) - { - if (!string.IsNullOrWhiteSpace(alarms.SubscriptionExpression)) - { - return alarms.SubscriptionExpression; - } - if (!string.IsNullOrWhiteSpace(alarms.DefaultArea)) - { - return $@"\\{Environment.MachineName}\Galaxy!{alarms.DefaultArea}"; - } - return string.Empty; - } } diff --git a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs index 1f1e67b..5b7eae4 100644 --- a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ public static class SessionServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); diff --git a/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs b/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs deleted file mode 100644 index ef19f52..0000000 --- a/src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System.Runtime.CompilerServices; -using Google.Protobuf.WellKnownTypes; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Grpc; - -namespace MxGateway.Server.Sessions; - -/// -/// Production that routes the public -/// AcknowledgeAlarm + QueryActiveAlarms RPCs through the -/// worker pipe IPC. DI binds this dispatcher; -/// is only the null fallback used when no dispatcher is registered. -/// -/// -/// -/// QueryActiveAlarms issues a -/// over the pipe and yields -/// each from the -/// . -/// -/// -/// AcknowledgeAlarm accepts either form of -/// : a canonical -/// GUID forwards as an ; a -/// Provider!Group.Tag reference is parsed by -/// and forwarded as an -/// . Any other reference -/// returns an InvalidRequest diagnostic. -/// -/// -public sealed class WorkerAlarmRpcDispatcher( - ISessionRegistry sessionRegistry, - TimeProvider? timeProvider = null) : IAlarmRpcDispatcher -{ - private readonly ISessionRegistry sessionRegistry = sessionRegistry - ?? throw new ArgumentNullException(nameof(sessionRegistry)); - private readonly TimeProvider timeProvider = timeProvider ?? TimeProvider.System; - - /// - /// Parse a full alarm reference of the form Provider!Group.Tag - /// into its components. Convention: the first ! separates - /// provider from Group.Tag; the first . after the - /// ! separates group from tag (the tag itself may contain - /// more dots — e.g. TestMachine_001.TestAlarm001). - /// - /// true on a well-formed reference; false otherwise. - public static bool TryParseAlarmReference( - string? reference, - out string providerName, - out string groupName, - out string alarmName) - { - providerName = string.Empty; - groupName = string.Empty; - alarmName = string.Empty; - if (string.IsNullOrWhiteSpace(reference)) return false; - - int bang = reference!.IndexOf('!'); - if (bang <= 0 || bang == reference.Length - 1) return false; - - string left = reference[..bang]; - string right = reference[(bang + 1)..]; - int dot = right.IndexOf('.'); - if (dot <= 0 || dot == right.Length - 1) return false; - - providerName = left; - groupName = right[..dot]; - alarmName = right[(dot + 1)..]; - return true; - } - - /// - public async Task AcknowledgeAsync( - AcknowledgeAlarmRequest request, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession? session) || session is null) - { - return new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = MxAccessGrpcMapper.SessionNotFound( - $"Session '{request.SessionId}' not found."), - DiagnosticMessage = "AcknowledgeAlarm: session not found.", - }; - } - - WorkerCommand workerCommand; - if (Guid.TryParse(request.AlarmFullReference, out Guid guid)) - { - workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.AcknowledgeAlarm, - AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand - { - AlarmGuid = guid.ToString(), - Comment = request.Comment ?? string.Empty, - OperatorUser = request.OperatorUser ?? string.Empty, - // Operator node/domain/full-name are not on the public - // RPC surface today; pass empty strings so the worker - // honours the existing AcknowledgeAlarmCommand schema. - OperatorNode = string.Empty, - OperatorDomain = string.Empty, - OperatorFullName = string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - } - else if (TryParseAlarmReference( - request.AlarmFullReference, - out string providerName, - out string groupName, - out string alarmName)) - { - workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.AcknowledgeAlarmByName, - AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand - { - AlarmName = alarmName, - ProviderName = providerName, - GroupName = groupName, - Comment = request.Comment ?? string.Empty, - OperatorUser = request.OperatorUser ?? string.Empty, - OperatorNode = string.Empty, - OperatorDomain = string.Empty, - OperatorFullName = string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - } - else - { - return new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.InvalidRequest, - Message = "AlarmFullReference must be a canonical GUID or 'Provider!Group.Tag' format.", - }, - DiagnosticMessage = $"AcknowledgeAlarm received unrecognized reference '{request.AlarmFullReference}'.", - }; - } - - WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken) - .ConfigureAwait(false); - - MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply - { - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.ProtocolViolation, - Message = "Worker reply did not include an MxCommandReply.", - }, - }; - - AcknowledgeAlarmReply reply = new AcknowledgeAlarmReply - { - SessionId = request.SessionId, - CorrelationId = request.ClientCorrelationId, - ProtocolStatus = mxReply.ProtocolStatus ?? MxAccessGrpcMapper.Ok(), - DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty, - }; - if (mxReply.HasHresult) - { - reply.Hresult = mxReply.Hresult; - } - return reply; - } - - /// - public async IAsyncEnumerable QueryActiveAlarmsAsync( - QueryActiveAlarmsRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession? session) || session is null) - { - // Server-019: align with AcknowledgeAsync's missing-session handling and - // surface a SessionNotFound error rather than yielding an empty stream. - // QueryActiveAlarms is server-streaming, so a thrown exception is the - // cleaner fit than an in-band ProtocolStatus; MxAccessGatewayService maps - // SessionManagerException(SessionNotFound) to gRPC NotFound. - throw new SessionManagerException( - SessionManagerErrorCode.SessionNotFound, - $"Session '{request.SessionId}' not found."); - } - - WorkerCommand workerCommand = new WorkerCommand - { - Command = new MxCommand - { - Kind = MxCommandKind.QueryActiveAlarms, - QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand - { - AlarmFilterPrefix = request.AlarmFilterPrefix ?? string.Empty, - }, - }, - EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()), - }; - - WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken) - .ConfigureAwait(false); - - MxCommandReply? mxReply = workerReply.Reply; - if (mxReply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) yield break; - - QueryActiveAlarmsReplyPayload? payload = mxReply.QueryActiveAlarms; - if (payload is null) yield break; - - foreach (ActiveAlarmSnapshot snapshot in payload.Snapshots) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return snapshot; - } - } -} diff --git a/src/MxGateway.Server/appsettings.json b/src/MxGateway.Server/appsettings.json index 2c5a91b..4d4da73 100644 --- a/src/MxGateway.Server/appsettings.json +++ b/src/MxGateway.Server/appsettings.json @@ -71,7 +71,7 @@ "Enabled": true, "SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV", "DefaultArea": "", - "RequireSubscribeOnOpen": false + "ReconcileIntervalSeconds": 30 } } } diff --git a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs b/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs index 0ad0edd..b36d59a 100644 --- a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +++ b/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs @@ -21,7 +21,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Contains(service.Methods, method => method.Name == "Invoke"); Assert.Contains(service.Methods, method => method.Name == "StreamEvents"); Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm"); - Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms"); + Assert.Contains(service.Methods, method => method.Name == "StreamAlarms"); } /// Verifies that worker envelope descriptor contains required correlation fields. @@ -306,7 +306,6 @@ public sealed class ProtobufContractRoundTripTests { var original = new AcknowledgeAlarmRequest { - SessionId = "session-1", ClientCorrelationId = "client-correlation-7", AlarmFullReference = "Tank01.Level.HiHi", Comment = "shift handover", @@ -324,7 +323,6 @@ public sealed class ProtobufContractRoundTripTests { var original = new AcknowledgeAlarmReply { - SessionId = "session-1", CorrelationId = "gateway-correlation-7", ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, Hresult = 0, @@ -420,25 +418,23 @@ public sealed class ProtobufContractRoundTripTests Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState); } - /// Verifies that QueryActiveAlarmsRequest round-trips empty filter prefix. + /// Verifies that StreamAlarmsRequest round-trips with and without a filter prefix. [Fact] - public void QueryActiveAlarmsRequest_RoundTripsWithAndWithoutFilter() + public void StreamAlarmsRequest_RoundTripsWithAndWithoutFilter() { - var withoutFilter = new QueryActiveAlarmsRequest + var withoutFilter = new StreamAlarmsRequest { - SessionId = "session-1", ClientCorrelationId = "client-correlation-8", }; - var withFilter = new QueryActiveAlarmsRequest + var withFilter = new StreamAlarmsRequest { - SessionId = "session-1", ClientCorrelationId = "client-correlation-9", AlarmFilterPrefix = "Tank01.", }; - Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray())); - Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray())); + Assert.Equal(withoutFilter, StreamAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray())); + Assert.Equal(withFilter, StreamAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray())); } /// Verifies that an MxValue carrying a raw_value bytes payload round-trips. diff --git a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index 29ae84d..0c06f56 100644 --- a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -188,7 +188,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests mapper, eventStreamService, _metrics, - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } /// diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs index fcb276e..fe04d10 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -612,7 +612,8 @@ public sealed class MxAccessGatewayServiceConstraintTests new MxAccessGrpcMapper(), new FakeEventStreamService(sessionManager), new GatewayMetrics(), - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } private static FakeSessionManager CreateSessionManagerWithSeed() diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 97be331..7aeef0b 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -92,54 +92,6 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(1, sessionManager.InvokeCount); } - /// - /// Verifies that AcknowledgeAlarm maps a genuinely missing session to NotFound via - /// the service's own ResolveSession lookup rather than an injected exception. - /// - [Fact] - public async Task AcknowledgeAlarm_WhenSessionMissing_ThrowsNotFound() - { - FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; - MxAccessGatewayService service = CreateService(sessionManager); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.AcknowledgeAlarm( - new AcknowledgeAlarmRequest - { - SessionId = "session-missing", - AlarmFullReference = "Tank01.Level.HiHi", - OperatorUser = "alice", - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.NotFound, exception.StatusCode); - Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal); - } - - /// - /// Verifies that QueryActiveAlarms maps a genuinely missing session to NotFound via - /// the service's own ResolveSession lookup rather than an injected exception. - /// - [Fact] - public async Task QueryActiveAlarms_WhenSessionMissing_ThrowsNotFound() - { - FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true }; - MxAccessGatewayService service = CreateService(sessionManager); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.QueryActiveAlarms( - new QueryActiveAlarmsRequest - { - SessionId = "session-missing", - AlarmFilterPrefix = "Tank01.", - }, - new RecordingServerStreamWriter(), - new TestServerCallContext())); - - Assert.Equal(StatusCode.NotFound, exception.StatusCode); - Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal); - } - /// Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched. [Fact] public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager() @@ -301,32 +253,13 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } - // ===== PR A.4 — AcknowledgeAlarm + QueryActiveAlarms handler contract ===== + // ===== AcknowledgeAlarm + StreamAlarms handler contract ===== // - // Worker-side dispatch (translating AcknowledgeAlarm to MxAccess Acknowledge, - // walking the active-alarm collection for QueryActiveAlarms) is gated on PR - // A.2's dev-rig validation. These tests pin the public surface so the worker - // wiring lands without changing observable behaviour for clients. + // AcknowledgeAlarm validates alarm_full_reference then delegates to the + // session-less IGatewayAlarmService; StreamAlarms forwards the central + // alarm feed. CreateService injects FakeGatewayAlarmService. - /// Verifies AcknowledgeAlarm rejects empty session_id. - [Fact] - public async Task AcknowledgeAlarm_WithMissingSessionId_ThrowsInvalidArgument() - { - MxAccessGatewayService service = CreateService(new FakeSessionManager()); - - RpcException exception = await Assert.ThrowsAsync( - async () => await service.AcknowledgeAlarm( - new AcknowledgeAlarmRequest - { - AlarmFullReference = "Tank01.Level.HiHi", - OperatorUser = "alice", - }, - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - } - - /// Verifies AcknowledgeAlarm rejects empty alarm_full_reference. + /// Verifies AcknowledgeAlarm rejects an empty alarm_full_reference. [Fact] public async Task AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument() { @@ -334,71 +267,47 @@ public sealed class MxAccessGatewayServiceTests RpcException exception = await Assert.ThrowsAsync( async () => await service.AcknowledgeAlarm( - new AcknowledgeAlarmRequest - { - SessionId = "session-1", - OperatorUser = "alice", - }, + new AcknowledgeAlarmRequest { OperatorUser = "alice" }, new TestServerCallContext())); Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } - /// Verifies AcknowledgeAlarm returns OK with a "dispatcher not registered" diagnostic when DI omits the dispatcher. + /// Verifies AcknowledgeAlarm delegates a valid request to the alarm service. [Fact] - public async Task AcknowledgeAlarm_WithValidRequest_ReturnsOkWithNotRegisteredDiagnostic() + public async Task AcknowledgeAlarm_WithValidRequest_DelegatesToAlarmService() { MxAccessGatewayService service = CreateService(new FakeSessionManager()); AcknowledgeAlarmReply reply = await service.AcknowledgeAlarm( new AcknowledgeAlarmRequest { - SessionId = "session-1", ClientCorrelationId = "corr-1", - AlarmFullReference = "Tank01.Level.HiHi", + AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi", Comment = "investigating", OperatorUser = "alice", }, new TestServerCallContext()); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal("session-1", reply.SessionId); Assert.Equal("corr-1", reply.CorrelationId); - Assert.Contains("not registered", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); } - /// Verifies QueryActiveAlarms rejects empty session_id. + /// Verifies StreamAlarms forwards the central alarm feed, ending with snapshot_complete. [Fact] - public async Task QueryActiveAlarms_WithMissingSessionId_ThrowsInvalidArgument() + public async Task StreamAlarms_ForwardsTheCentralAlarmFeed() { MxAccessGatewayService service = CreateService(new FakeSessionManager()); + RecordingServerStreamWriter sink = new(); - RpcException exception = await Assert.ThrowsAsync( - async () => await service.QueryActiveAlarms( - new QueryActiveAlarmsRequest(), - new RecordingServerStreamWriter(), - new TestServerCallContext())); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - } - - /// Verifies QueryActiveAlarms streams zero snapshots until PR A.2 wires the worker walk. - [Fact] - public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots() - { - MxAccessGatewayService service = CreateService(new FakeSessionManager()); - RecordingServerStreamWriter sink = new(); - - await service.QueryActiveAlarms( - new QueryActiveAlarmsRequest - { - SessionId = "session-1", - AlarmFilterPrefix = "Tank01.", - }, + await service.StreamAlarms( + new StreamAlarmsRequest(), sink, new TestServerCallContext()); - Assert.Empty(sink.Messages); + Assert.Contains( + sink.Messages, + message => message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete); } /// Verifies OpenSession advertises the alarm RPC capability strings. @@ -433,7 +342,8 @@ public sealed class MxAccessGatewayServiceTests new MxAccessGrpcMapper(), new FakeEventStreamService(sessionManager), metrics ?? new GatewayMetrics(), - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } private static ApiKeyIdentity CreateIdentity() diff --git a/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs b/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs deleted file mode 100644 index 4b5f2e8..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using MxGateway.Contracts.Proto; -using MxGateway.Server.Sessions; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// Pins the null-fallback dispatcher's behaviour: AcknowledgeAsync -/// returns OK with a "dispatcher not registered" diagnostic and -/// QueryActiveAlarmsAsync yields an empty stream. Production binds -/// WorkerAlarmRpcDispatcher in DI; this fallback is only used -/// when no dispatcher is registered (DI omission / standalone tests). -/// -public sealed class NotWiredAlarmRpcDispatcherTests -{ - [Fact] - public async Task AcknowledgeAsync_WhenNotWired_ReturnsOkWithNotRegisteredDiagnostic() - { - IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher(); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "session-1", - ClientCorrelationId = "corr-1", - AlarmFullReference = "Tank01.Level.HiHi", - Comment = "investigating", - OperatorUser = "alice", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal("session-1", reply.SessionId); - Assert.Equal("corr-1", reply.CorrelationId); - Assert.Contains("not registered", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_WhenNotWired_YieldsNoSnapshots() - { - IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher(); - - int count = 0; - await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "session-1" }, - CancellationToken.None)) - { - count++; - } - - Assert.Equal(0, count); - } -} diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs deleted file mode 100644 index e4c3175..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using System.Runtime.CompilerServices; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Options; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Configuration; -using MxGateway.Server.Metrics; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// Pins the alarm auto-subscribe hook on session open. Runs in -/// its own file because the cases are orthogonal to -/// (alarms-disabled vs. -/// alarms-enabled lanes), and the fake worker client below verifies -/// the issued SubscribeAlarms command shape directly. -/// -public sealed class SessionManagerAlarmAutoSubscribeTests -{ - [Fact] - public async Task OpenSessionAsync_DoesNotAutoSubscribe_WhenAlarmsDisabled() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions { Enabled = false }); - - await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_AutoSubscribes_WhenEnabledWithExpression() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - Assert.Equal(@"\\HOST\Galaxy!Area1", - worker.LastSubscribeAlarmsCommand!.SubscriptionExpression); - } - - [Fact] - public async Task OpenSessionAsync_FallsBackToDefaultArea_WhenExpressionEmpty() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - DefaultArea = "DEV", - }); - - await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - Assert.Contains(@"\Galaxy!DEV", - worker.LastSubscribeAlarmsCommand!.SubscriptionExpression); - } - - [Fact] - public async Task OpenSessionAsync_Succeeds_WhenAutoSubscribeFailsWithRequireOff() - { - // Worker rejects the SubscribeAlarms command. With RequireSubscribeOnOpen=false - // (the default), the session still opens — alarm-side commands later return - // "not subscribed", but data subscriptions work. - AlarmAutoSubscribeWorkerClient worker = new() - { - SubscribeAlarmsReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "wnwrap subscribe failed", - }, - DiagnosticMessage = "alarm provider unavailable", - }, - }; - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - RequireSubscribeOnOpen = false, - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(1, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_Throws_WhenAutoSubscribeFailsWithRequireOn() - { - AlarmAutoSubscribeWorkerClient worker = new() - { - SubscribeAlarmsReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "wnwrap subscribe failed", - }, - }, - }; - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - RequireSubscribeOnOpen = true, - }); - - await Assert.ThrowsAsync( - async () => await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None)); - } - - /// - /// Server-006 regression: when auto-subscribe throws after - /// SessionOpened() incremented the open-session gauge, the failed - /// open must not leave mxgateway.sessions.open over-counted. - /// - [Fact] - public async Task OpenSessionAsync_DoesNotLeakOpenSessionGauge_WhenAutoSubscribeFailsWithRequireOn() - { - AlarmAutoSubscribeWorkerClient worker = new() - { - SubscribeAlarmsReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "wnwrap subscribe failed", - }, - }, - }; - using GatewayMetrics metrics = new(); - SessionManager manager = NewManager( - worker, - alarms: new AlarmsOptions - { - Enabled = true, - SubscriptionExpression = @"\\HOST\Galaxy!Area1", - RequireSubscribeOnOpen = true, - }, - metrics: metrics); - - await Assert.ThrowsAsync( - async () => await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None)); - - Assert.Equal(0, metrics.GetSnapshot().OpenSessions); - } - - [Fact] - public async Task OpenSessionAsync_Throws_WhenEnabledButNoExpressionAndRequireOn() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - // No SubscriptionExpression and no DefaultArea. - RequireSubscribeOnOpen = true, - }); - - await Assert.ThrowsAsync( - async () => await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None)); - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - [Fact] - public async Task OpenSessionAsync_Succeeds_WhenEnabledButNoExpressionAndRequireOff() - { - AlarmAutoSubscribeWorkerClient worker = new(); - SessionManager manager = NewManager(worker, alarms: new AlarmsOptions - { - Enabled = true, - // No SubscriptionExpression and no DefaultArea — default require=false. - }); - - GatewaySession session = await manager.OpenSessionAsync( - CreateOpenRequest(), "client-1", CancellationToken.None); - - Assert.Equal(SessionState.Ready, session.State); - Assert.Equal(0, worker.SubscribeAlarmsInvokeCount); - } - - private static SessionManager NewManager( - AlarmAutoSubscribeWorkerClient worker, - AlarmsOptions alarms, - GatewayMetrics? metrics = null) - { - FakeSessionWorkerClientFactory factory = new(worker); - GatewayOptions options = new GatewayOptions - { - Sessions = new SessionOptions - { - DefaultCommandTimeoutSeconds = 30, - MaxSessions = 64, - DefaultLeaseSeconds = 1800, - }, - Worker = new WorkerOptions - { - StartupTimeoutSeconds = 30, - ShutdownTimeoutSeconds = 10, - }, - Alarms = alarms, - }; - return new SessionManager( - new SessionRegistry(), - factory, - Options.Create(options), - metrics ?? new GatewayMetrics()); - } - - private static SessionOpenRequest CreateOpenRequest() - { - return new SessionOpenRequest( - RequestedBackend: null, - ClientSessionName: "test-session", - ClientCorrelationId: "client-correlation-1", - CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5))); - } - - private sealed class FakeSessionWorkerClientFactory(IWorkerClient client) : ISessionWorkerClientFactory - { - public Task CreateAsync( - GatewaySession session, - CancellationToken cancellationToken) - { - return Task.FromResult(client); - } - } - - private sealed class AlarmAutoSubscribeWorkerClient : IWorkerClient - { - public string SessionId { get; } = "session-1"; - public int? ProcessId { get; } = 1234; - public WorkerClientState State { get; set; } = WorkerClientState.Ready; - public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; - - public int SubscribeAlarmsInvokeCount { get; private set; } - public SubscribeAlarmsCommand? LastSubscribeAlarmsCommand { get; private set; } - public Func? SubscribeAlarmsReplyFactory { get; init; } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task InvokeAsync( - WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) - { - if (command.Command?.Kind == MxCommandKind.SubscribeAlarms) - { - SubscribeAlarmsInvokeCount++; - LastSubscribeAlarmsCommand = command.Command.SubscribeAlarms; - MxCommandReply reply = SubscribeAlarmsReplyFactory?.Invoke(command) - ?? new MxCommandReply - { - Kind = MxCommandKind.SubscribeAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }; - return Task.FromResult(new WorkerCommandReply { Reply = reply }); - } - return Task.FromResult(new WorkerCommandReply - { - Reply = new MxCommandReply - { - Kind = command.Command?.Kind ?? MxCommandKind.Unspecified, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.Ok, - Message = "OK", - }, - }, - }); - } - - public async IAsyncEnumerable ReadEventsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - await Task.CompletedTask; - yield break; - } - - public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) - => Task.CompletedTask; - public void Kill(string reason) { } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs b/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs deleted file mode 100644 index d6c7367..0000000 --- a/src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs +++ /dev/null @@ -1,393 +0,0 @@ -using System.Runtime.CompilerServices; -using MxGateway.Contracts.Proto; -using MxGateway.Server.Sessions; -using MxGateway.Server.Workers; - -namespace MxGateway.Tests.Gateway.Sessions; - -/// -/// Pins the production 's behaviour: -/// resolves the session by id, issues the matching MxCommand over the -/// worker pipe, and unwraps the reply into AcknowledgeAlarmReply or the -/// ActiveAlarmSnapshot stream. -/// -public sealed class WorkerAlarmRpcDispatcherTests -{ - [Fact] - public async Task AcknowledgeAsync_WhenSessionMissing_ReturnsSessionNotFound() - { - SessionRegistry registry = new(); - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "missing", - ClientCorrelationId = "c1", - AlarmFullReference = Guid.NewGuid().ToString(), - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.SessionNotFound, reply.ProtocolStatus.Code); - } - - [Fact] - public async Task AcknowledgeAsync_WithGuidReference_ForwardsGuidAndReturnsNativeStatus() - { - SessionRegistry registry = new(); - Guid alarmGuid = Guid.NewGuid(); - FakeAlarmWorkerClient worker = new() - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.AcknowledgeAlarm, command.Command.Kind); - Assert.Equal(alarmGuid.ToString(), command.Command.AcknowledgeAlarmCommand.AlarmGuid); - Assert.Equal("ack", command.Command.AcknowledgeAlarmCommand.Comment); - Assert.Equal("alice", command.Command.AcknowledgeAlarmCommand.OperatorUser); - return new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarm, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - Hresult = 0, - AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 }, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - ClientCorrelationId = "c1", - AlarmFullReference = alarmGuid.ToString(), - Comment = "ack", - OperatorUser = "alice", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.Equal(0, reply.Hresult); - Assert.Equal("s1", reply.SessionId); - Assert.Equal("c1", reply.CorrelationId); - Assert.Equal(1, worker.InvokeCount); - } - - [Fact] - public async Task AcknowledgeAsync_WhenWorkerFails_PropagatesWorkerDiagnostic() - { - SessionRegistry registry = new(); - FakeAlarmWorkerClient worker = new() - { - ReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarm, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "AVEVA Acknowledge failed.", - }, - Hresult = -123, - DiagnosticMessage = "AVEVA AlarmAckByGUID returned non-zero status -123.", - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - AlarmFullReference = Guid.NewGuid().ToString(), - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); - Assert.Equal(-123, reply.Hresult); - Assert.Contains("-123", reply.DiagnosticMessage); - } - - [Theory] - [InlineData("Galaxy!TestArea.TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "TestMachine_001.TestAlarm001")] - [InlineData("Galaxy!Area.Tag", "Galaxy", "Area", "Tag")] - [InlineData("Provider!Group.Tag.With.Dots", "Provider", "Group", "Tag.With.Dots")] - public void TryParseAlarmReference_WithProviderGroupTag_DecomposesParts( - string reference, string expectedProvider, string expectedGroup, string expectedName) - { - Assert.True(WorkerAlarmRpcDispatcher.TryParseAlarmReference( - reference, out string provider, out string group, out string name)); - Assert.Equal(expectedProvider, provider); - Assert.Equal(expectedGroup, group); - Assert.Equal(expectedName, name); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - [InlineData("no-bang-here")] - [InlineData("!Group.Tag")] // empty provider - [InlineData("Galaxy!")] // bang at end - [InlineData("Galaxy!Group")] // missing dot - [InlineData("Galaxy!.Tag")] // empty group - [InlineData("Galaxy!Group.")] // empty tag - public void TryParseAlarmReference_WithMalformedReference_ReturnsFalse(string? reference) - { - Assert.False(WorkerAlarmRpcDispatcher.TryParseAlarmReference( - reference, out _, out _, out _)); - } - - [Fact] - public async Task AcknowledgeAsync_WithProviderGroupTagReference_RoutesViaAckByName() - { - SessionRegistry registry = new(); - AcknowledgeAlarmByNameCommand? observed = null; - FakeAlarmWorkerClient worker = new() - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command.Command.Kind); - observed = command.Command.AcknowledgeAlarmByNameCommand; - return new MxCommandReply - { - Kind = MxCommandKind.AcknowledgeAlarmByName, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - Hresult = 0, - AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 }, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - ClientCorrelationId = "c1", - AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", - Comment = "ack-by-name", - OperatorUser = "bob", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); - Assert.NotNull(observed); - Assert.Equal("TestMachine_001.TestAlarm001", observed!.AlarmName); - Assert.Equal("Galaxy", observed.ProviderName); - Assert.Equal("TestArea", observed.GroupName); - Assert.Equal("bob", observed.OperatorUser); - Assert.Equal("ack-by-name", observed.Comment); - } - - [Fact] - public async Task AcknowledgeAsync_WithUnparseableReference_ReturnsInvalidRequest() - { - SessionRegistry registry = new(); - FakeAlarmWorkerClient worker = new(); - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "s1", - AlarmFullReference = "no-bang-no-dot", - }, - CancellationToken.None); - - Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); - Assert.Equal(0, worker.InvokeCount); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_WithPayloadSnapshots_YieldsEachSnapshot() - { - SessionRegistry registry = new(); - FakeAlarmWorkerClient worker = new() - { - ReplyFactory = command => - { - Assert.Equal(MxCommandKind.QueryActiveAlarms, command.Command.Kind); - QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); - payload.Snapshots.Add(new ActiveAlarmSnapshot - { - AlarmFullReference = "Galaxy!A.T1", - CurrentState = AlarmConditionState.Active, - Severity = 500, - }); - payload.Snapshots.Add(new ActiveAlarmSnapshot - { - AlarmFullReference = "Galaxy!A.T2", - CurrentState = AlarmConditionState.ActiveAcked, - Severity = 100, - }); - return new MxCommandReply - { - Kind = MxCommandKind.QueryActiveAlarms, - ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" }, - QueryActiveAlarms = payload, - }; - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - List collected = new(); - await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "s1" }, - CancellationToken.None)) - { - collected.Add(snap); - } - - Assert.Equal(2, collected.Count); - Assert.Equal("Galaxy!A.T1", collected[0].AlarmFullReference); - Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference); - } - - /// - /// Server-019 regression: QueryActiveAlarmsAsync used to silently - /// yield break when the session id was not in the registry, while the - /// peer AcknowledgeAsync returned SessionNotFound. Both methods - /// now signal a missing session — QueryActiveAlarms throws a - /// with - /// (the gateway gRPC - /// layer maps it to gRPC NotFound), aligning the dispatcher's - /// missing-session contract across the two RPCs. - /// - [Fact] - public async Task QueryActiveAlarmsAsync_WhenSessionMissing_ThrowsSessionNotFound() - { - SessionRegistry registry = new(); - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - SessionManagerException exception = await Assert.ThrowsAsync(async () => - { - await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "missing" }, - CancellationToken.None)) - { - // No yield expected — the throw happens before the first iteration. - } - }); - - Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode); - - // Peer-method parity: AcknowledgeAsync still signals SessionNotFound (as an - // in-band ProtocolStatus, since it's a unary RPC). The two methods now agree - // that a missing session is an error, not an empty success. - AcknowledgeAlarmReply ackReply = await dispatcher.AcknowledgeAsync( - new AcknowledgeAlarmRequest - { - SessionId = "missing", - AlarmFullReference = Guid.NewGuid().ToString(), - }, - CancellationToken.None); - Assert.Equal(ProtocolStatusCode.SessionNotFound, ackReply.ProtocolStatus.Code); - } - - [Fact] - public async Task QueryActiveAlarmsAsync_WhenWorkerFails_YieldsEmpty() - { - SessionRegistry registry = new(); - FakeAlarmWorkerClient worker = new() - { - ReplyFactory = _ => new MxCommandReply - { - Kind = MxCommandKind.QueryActiveAlarms, - ProtocolStatus = new ProtocolStatus - { - Code = ProtocolStatusCode.MxaccessFailure, - Message = "alarm consumer not subscribed", - }, - }, - }; - GatewaySession session = NewSession("s1"); - session.AttachWorkerClient(worker); - session.MarkReady(); - registry.TryAdd(session); - - WorkerAlarmRpcDispatcher dispatcher = new(registry); - - List collected = new(); - await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync( - new QueryActiveAlarmsRequest { SessionId = "s1" }, - CancellationToken.None)) - { - collected.Add(snap); - } - - Assert.Empty(collected); - } - - private static GatewaySession NewSession(string sessionId) - { - return new( - sessionId, - "mxaccess", - $"mxaccess-gateway-1-{sessionId}", - "nonce", - "client-1", - "test-session", - "client-correlation-1", - commandTimeout: TimeSpan.FromSeconds(30), - startupTimeout: TimeSpan.FromSeconds(5), - shutdownTimeout: TimeSpan.FromSeconds(5), - leaseDuration: TimeSpan.FromMinutes(30), - openedAt: DateTimeOffset.UtcNow); - } - - private sealed class FakeAlarmWorkerClient : IWorkerClient - { - public string SessionId { get; } = "session-1"; - public int? ProcessId { get; } = 1; - public WorkerClientState State { get; } = WorkerClientState.Ready; - public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; - - public Func? ReplyFactory { get; set; } - public int InvokeCount { get; private set; } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task InvokeAsync( - WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) - { - InvokeCount++; - MxCommandReply reply = ReplyFactory?.Invoke(command) ?? new MxCommandReply(); - return Task.FromResult(new WorkerCommandReply { Reply = reply }); - } - - public async IAsyncEnumerable ReadEventsAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - await Task.CompletedTask; - yield break; - } - - public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; - public void Kill(string reason) { } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index b741803..55e927c 100644 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -266,7 +266,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( - new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" }, + new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => Task.FromResult(new AcknowledgeAlarmReply()))); @@ -284,7 +284,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests bool handlerRan = false; AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler( - new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" }, + new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => { @@ -310,7 +310,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( - new QueryActiveAlarmsRequest { SessionId = "session-1" }, + new StreamAlarmsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); @@ -329,7 +329,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( - new QueryActiveAlarmsRequest { SessionId = "session-1" }, + new StreamAlarmsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => @@ -352,7 +352,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests new MxAccessGrpcMapper(), new NoOpEventStreamService(), new GatewayMetrics(), - NullLogger.Instance); + NullLogger.Instance, + new FakeGatewayAlarmService()); } private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs index 137f146..d2ad3ad 100644 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs @@ -14,7 +14,7 @@ public sealed class GatewayGrpcScopeResolverTests [InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)] [InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)] [InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)] - [InlineData(typeof(QueryActiveAlarmsRequest), GatewayScopes.EventsRead)] + [InlineData(typeof(StreamAlarmsRequest), GatewayScopes.EventsRead)] [InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)] [InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)] [InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)] diff --git a/src/MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs b/src/MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs new file mode 100644 index 0000000..bf32ada --- /dev/null +++ b/src/MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Alarms; + +namespace MxGateway.Tests.TestSupport; + +/// +/// test double — serves a scripted +/// active-alarm set and acknowledges every request with an OK status, +/// so gRPC service tests can exercise the alarm handlers without the +/// real gateway alarm monitor or a worker. +/// +public sealed class FakeGatewayAlarmService : IGatewayAlarmService +{ + /// + public GatewayAlarmMonitorState State { get; set; } = GatewayAlarmMonitorState.Monitoring; + + /// + public string? LastError { get; set; } + + /// + public int? WorkerProcessId { get; set; } + + /// + public IReadOnlyList CurrentAlarms { get; set; } = []; + + /// + public async IAsyncEnumerable StreamAsync( + string? alarmFilterPrefix, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (ActiveAlarmSnapshot alarm in CurrentAlarms) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new AlarmFeedMessage { ActiveAlarm = alarm }; + } + + yield return new AlarmFeedMessage { SnapshotComplete = true }; + await Task.CompletedTask.ConfigureAwait(false); + } + + /// + public Task AcknowledgeAsync( + AcknowledgeAlarmRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(new AcknowledgeAlarmReply + { + CorrelationId = request.ClientCorrelationId, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + DiagnosticMessage = string.Empty, + }); + } +}