using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry; namespace ZB.MOM.WW.OtOpcUa.Host.Grpc; /// /// Node-side projection of a domain onto its wire /// envelope (per-cluster mesh Phase 5). The exact reverse of /// telemetry.proto: it transcribes every domain field onto the matching proto field and /// selects the envelope's oneof arm from the subtype. /// /// /// /// DateTime → Timestamp is Kind-defensive. throws /// unless the source is . Producers /// use today, but a stray or /// value must never crash the stream — so a Local value /// is converted and an Unspecified value is assumed already-UTC (matching the producer intent /// and preserving the instant on any machine). /// /// /// Nullable presence is preserved. A null nullable-DateTime leaves the proto /// Timestamp unset (absent == null per the contract); a null optional string leaves the /// proto field unset so the generated Has… presence distinguishes null from "". /// /// public static class TelemetryProtoMapNode { /// Projects onto a wire . /// The domain telemetry item to project. /// Stream correlation id echoed on every envelope. /// The wire envelope with the matching oneof arm populated. public static TelemetryEvent ToProto(TelemetryItem item, string correlationId) { ArgumentNullException.ThrowIfNull(item); return item switch { TelemetryItem.Alarm a => new TelemetryEvent { CorrelationId = correlationId, AlarmTransition = MapAlarm(a.E), }, TelemetryItem.Script s => new TelemetryEvent { CorrelationId = correlationId, ScriptLog = MapScript(s.E), }, TelemetryItem.Health h => new TelemetryEvent { CorrelationId = correlationId, DriverHealth = MapHealth(h.E), }, TelemetryItem.Resilience r => new TelemetryEvent { CorrelationId = correlationId, DriverResilience = MapResilience(r.E), }, _ => throw new ArgumentOutOfRangeException( nameof(item), item.GetType().Name, "Unknown TelemetryItem subtype"), }; } private static AlarmTransition MapAlarm(AlarmTransitionEvent e) { // Required (non-optional) proto string setters throw ArgumentNullException on null, and the // domain records carry no runtime null guard (nullable-ref annotations are compile-time only). // Coalesce every required string to "" so a stray null can never throw out of the pump and kill // the stream. The `optional` fields below keep their null-vs-"" presence guards. var msg = new AlarmTransition { AlarmId = e.AlarmId ?? "", EquipmentPath = e.EquipmentPath ?? "", AlarmName = e.AlarmName ?? "", TransitionKind = e.TransitionKind ?? "", Severity = e.Severity, Message = e.Message ?? "", User = e.User ?? "", TimestampUtc = ToUtcTimestamp(e.TimestampUtc), AlarmTypeName = e.AlarmTypeName ?? "", }; if (e.Comment is not null) msg.Comment = e.Comment; if (e.HistorizeToAveva is not null) msg.HistorizeToAveva = e.HistorizeToAveva.Value; msg.ReferencingEquipmentPaths.AddRange(e.ReferencingEquipmentPaths ?? Enumerable.Empty()); return msg; } private static ScriptLog MapScript(ScriptLogEntry e) { var msg = new ScriptLog { ScriptId = e.ScriptId ?? "", Level = e.Level ?? "", Message = e.Message ?? "", TimestampUtc = ToUtcTimestamp(e.TimestampUtc), }; if (e.VirtualTagId is not null) msg.VirtualTagId = e.VirtualTagId; if (e.AlarmId is not null) msg.AlarmId = e.AlarmId; if (e.EquipmentId is not null) msg.EquipmentId = e.EquipmentId; return msg; } private static DriverHealth MapHealth(DriverHealthChanged e) { var msg = new DriverHealth { ClusterId = e.ClusterId ?? "", DriverInstanceId = e.DriverInstanceId ?? "", State = e.State ?? "", ErrorCount5Min = e.ErrorCount5Min, PublishedUtc = ToUtcTimestamp(e.PublishedUtc), }; if (e.LastSuccessfulReadUtc is not null) msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value); if (e.LastError is not null) msg.LastError = e.LastError; if (e.RediscoveryNeededUtc is not null) msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value); if (e.RediscoveryReason is not null) msg.RediscoveryReason = e.RediscoveryReason; return msg; } private static DriverResilienceStatus MapResilience(DriverResilienceStatusChanged e) { var msg = new DriverResilienceStatus { DriverInstanceId = e.DriverInstanceId ?? "", HostName = e.HostName ?? "", BreakerOpen = e.BreakerOpen, ConsecutiveFailures = e.ConsecutiveFailures, CurrentInFlight = e.CurrentInFlight, LastSampledUtc = ToUtcTimestamp(e.LastSampledUtc), PublishedUtc = ToUtcTimestamp(e.PublishedUtc), }; if (e.LastBreakerOpenUtc is not null) msg.LastBreakerOpenUtc = ToUtcTimestamp(e.LastBreakerOpenUtc.Value); return msg; } /// /// Converts a domain to a proto without ever /// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed /// already-UTC (the producer contract — — with the wall-clock /// ticks preserved so the instant round-trips). /// private static Timestamp ToUtcTimestamp(DateTime dt) { var utc = dt.Kind switch { DateTimeKind.Utc => dt, DateTimeKind.Local => dt.ToUniversalTime(), _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc), }; return Timestamp.FromDateTime(utc); } }