From a845a6d2fdf6a2eb4506eda72dfab2b988fc8215 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 14:58:53 -0400 Subject: [PATCH] feat(mesh-phase5): telemetry.proto contract + oneof + contract-lock test Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Protos/TelemetryProtoContract.cs | 25 ++++++ .../Protos/telemetry.proto | 83 +++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Commons.csproj | 1 + .../TelemetryProtoContractTests.cs | 32 +++++++ 4 files changed, 141 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/TelemetryProtoContract.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TelemetryProtoContractTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/TelemetryProtoContract.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/TelemetryProtoContract.cs new file mode 100644 index 00000000..b251a542 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/TelemetryProtoContract.cs @@ -0,0 +1,25 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Protos; + +/// +/// The single source of truth for which variants the +/// Phase 5 telemetry transport actually handles. Both the contract-lock test (which proves this +/// list stays in lock-step with the generated oneof) and the later oneof↔domain converter +/// reference this array, so adding a fifth channel to telemetry.proto forces a matching +/// entry here or the build/test goes red. +/// +public static class TelemetryProtoContract +{ + /// + /// Exactly the four telemetry oneof cases mirrored from the domain records: + /// alerts / script-logs / driver-health / driver-resilience-status. + /// + public static readonly TelemetryEvent.EventOneofCase[] HandledCases = + [ + TelemetryEvent.EventOneofCase.AlarmTransition, + TelemetryEvent.EventOneofCase.ScriptLog, + TelemetryEvent.EventOneofCase.DriverHealth, + TelemetryEvent.EventOneofCase.DriverResilience, + ]; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto new file mode 100644 index 00000000..0997fb2e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/telemetry.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package telemetry.v1; + +option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1"; + +import "google/protobuf/timestamp.proto"; + +// Per-cluster mesh Phase 5: the four live-telemetry channels that used to fan out over +// DistributedPubSub (alerts / script-logs / driver-health / driver-resilience-status) now travel over +// ONE gRPC server-streaming contract. Central dials each driver node and opens Subscribe; the node +// streams its own live telemetry as a sequence of TelemetryEvent envelopes. +// +// Additive-only field evolution: never renumber or reuse a tag; a pre-field peer must read a new +// field's proto3 default correctly (that is why nullable domain fields are modelled with `optional` +// for explicit presence). Locked by the contract test in the Commons test project. +service TelemetryStreamService { + rpc Subscribe(TelemetryStreamRequest) returns (stream TelemetryEvent); +} + +message TelemetryStreamRequest { + string correlation_id = 1; +} + +message TelemetryEvent { + string correlation_id = 1; + oneof event { + AlarmTransition alarm_transition = 2; // <- "alerts" / AlarmTransitionEvent + ScriptLog script_log = 3; // <- "script-logs" / ScriptLogEntry + DriverHealth driver_health = 4; // <- "driver-health" / DriverHealthChanged + DriverResilienceStatus driver_resilience = 5; // <- "driver-resilience-status" / DriverResilienceStatusChanged + } +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts.AlarmTransitionEvent. +message AlarmTransition { + string alarm_id = 1; + string equipment_path = 2; + string alarm_name = 3; + string transition_kind = 4; // string in the record (Activated/Cleared/...); kept as string + int32 severity = 5; + string message = 6; + string user = 7; + google.protobuf.Timestamp timestamp_utc = 8; + string alarm_type_name = 9; // record default "AlarmCondition" + optional string comment = 10; // nullable in the record — presence distinguishes null from "" + optional bool historize_to_aveva = 11; // bool? in the record — three states (null / true / false) + repeated string referencing_equipment_paths = 12; // null in the record is treated as empty +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging.ScriptLogEntry. +message ScriptLog { + string script_id = 1; + string level = 2; // string in the record (Trace/Debug/Information/...); kept as string + string message = 3; + google.protobuf.Timestamp timestamp_utc = 4; + optional string virtual_tag_id = 5; // nullable in the record + optional string alarm_id = 6; // nullable in the record + optional string equipment_id = 7; // nullable in the record +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverHealthChanged. +message DriverHealth { + string cluster_id = 1; + string driver_instance_id = 2; + string state = 3; // DriverState-as-string in the record; kept as string + google.protobuf.Timestamp last_successful_read_utc = 4; // DateTime? — absent Timestamp encodes null + optional string last_error = 5; // nullable in the record + int32 error_count_5min = 6; + google.protobuf.Timestamp published_utc = 7; +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged. +message DriverResilienceStatus { + string driver_instance_id = 1; + string host_name = 2; + bool breaker_open = 3; + int32 consecutive_failures = 4; + int32 current_in_flight = 5; + google.protobuf.Timestamp last_breaker_open_utc = 6; // DateTime? — absent Timestamp encodes null + google.protobuf.Timestamp last_sampled_utc = 7; + google.protobuf.Timestamp published_utc = 8; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj index acad5d43..5e55544f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TelemetryProtoContractTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TelemetryProtoContractTests.cs new file mode 100644 index 00000000..855bb17a --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TelemetryProtoContractTests.cs @@ -0,0 +1,32 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Protos; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +/// +/// Contract-lock for the Phase 5 telemetry oneof. If a fifth variant is added to +/// telemetry.proto without adding a matching entry to +/// , this test goes red — the single guard that +/// keeps the wire contract and the handled-case set from silently drifting apart. +/// +public class TelemetryProtoContractTests +{ + [Fact] + public void EveryOneofVariant_IsAccountedFor() + { + var variants = System.Enum.GetValues() + .Where(c => c != TelemetryEvent.EventOneofCase.None).ToArray(); + + variants.ShouldBe(TelemetryProtoContract.HandledCases, ignoreOrder: true); + } + + [Fact] + public void The_service_base_and_client_types_generate() + { + // Referencing these types is the whole assertion — GrpcServices="Both" must emit both. + typeof(TelemetryStreamService.TelemetryStreamServiceBase).ShouldNotBeNull(); + typeof(TelemetryStreamService.TelemetryStreamServiceClient).ShouldNotBeNull(); + } +}