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;
// Aliased, not imported wholesale: Core.Abstractions also declares a DriverHealth, which would collide
// with the proto DriverHealth this file maps.
using HostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus;
using HostState = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostState;
// Same reason: the proto declares SkippedTag / SkippedTags alongside the domain records of near-identical
// name, so the domain side is named explicitly and the proto side reached through the wholesale import.
using SkippedTag = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTag;
using SkippedTagReport = ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry;
///
/// Central-side projection of a wire envelope back onto its domain
/// record (per-cluster mesh Phase 5). The exact reverse of TelemetryProtoMapNode: it
/// transcribes every proto field onto the matching domain field and selects the domain record from
/// the envelope's oneof arm.
///
///
///
/// Timestamp presence is honoured. A proto google.protobuf.Timestamp field is a
/// message: when the node left it unset (a null nullable-DateTime on the domain side) the
/// generated property is , so the nullable domain fields map via
/// ?.ToDateTime() and an absent Timestamp becomes . The
/// non-nullable domain fields (published / sampled / transition timestamps) are always set by
/// the node mapper, so they map via (which yields a
/// value).
///
///
/// A required Timestamp is defended, not assumed. Across a mixed-version fleet an
/// out-of-contract sender could leave a required Timestamp unset. throws
/// a clear naming the kind + field instead of letting a
/// bare escape — the client's per-event isolation then logs
/// it as a dropped malformed event and continues, because it is a code/version fault, never a
/// transport fault.
///
///
/// optional-string presence is honoured. A proto3 optional string that the node
/// left unset reads back as (via the generated Has… presence),
/// distinguishing null from the empty string; likewise the optional bool
/// historize_to_aveva round-trips its three states (null / true / false).
///
///
public static class TelemetryProtoMapCentral
{
///
/// Projects a wire onto its domain record, selecting the type from
/// the populated oneof arm. This switch is the coverage guard: every
/// value has an arm here, and an unset (or a
/// future unmapped) case throws so adding a fifth oneof case
/// without a converter fails the coverage test.
///
/// The wire envelope to project.
/// The domain record for the populated arm.
/// is null.
/// The envelope carries no handled oneof arm.
public static object MapEvent(TelemetryEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
return evt.EventCase switch
{
TelemetryEvent.EventOneofCase.AlarmTransition => ToAlarm(evt.AlarmTransition),
TelemetryEvent.EventOneofCase.ScriptLog => ToScript(evt.ScriptLog),
TelemetryEvent.EventOneofCase.DriverHealth => ToHealth(evt.DriverHealth),
TelemetryEvent.EventOneofCase.DriverResilience => ToResilience(evt.DriverResilience),
_ => throw new NotSupportedException(
$"TelemetryEvent carries no handled oneof arm (EventCase = {evt.EventCase})."),
};
}
/// Projects an onto an .
/// The proto sub-message.
/// The domain record.
public static AlarmTransitionEvent ToAlarm(AlarmTransition msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new AlarmTransitionEvent(
AlarmId: msg.AlarmId,
EquipmentPath: msg.EquipmentPath,
AlarmName: msg.AlarmName,
TransitionKind: msg.TransitionKind,
Severity: msg.Severity,
Message: msg.Message,
User: msg.User,
TimestampUtc: Required(msg.TimestampUtc, "AlarmTransition", "timestamp_utc"),
AlarmTypeName: msg.AlarmTypeName,
Comment: msg.HasComment ? msg.Comment : null,
HistorizeToAveva: msg.HasHistorizeToAveva ? msg.HistorizeToAveva : null,
ReferencingEquipmentPaths: msg.ReferencingEquipmentPaths.ToList());
}
/// Projects a onto a .
/// The proto sub-message.
/// The domain record.
public static ScriptLogEntry ToScript(ScriptLog msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new ScriptLogEntry(
ScriptId: msg.ScriptId,
Level: msg.Level,
Message: msg.Message,
TimestampUtc: Required(msg.TimestampUtc, "ScriptLog", "timestamp_utc"),
VirtualTagId: msg.HasVirtualTagId ? msg.VirtualTagId : null,
AlarmId: msg.HasAlarmId ? msg.AlarmId : null,
EquipmentId: msg.HasEquipmentId ? msg.EquipmentId : null);
}
/// Projects a onto a .
/// The proto sub-message.
/// The domain record.
public static DriverHealthChanged ToHealth(DriverHealth msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new DriverHealthChanged(
ClusterId: msg.ClusterId,
DriverInstanceId: msg.DriverInstanceId,
State: msg.State,
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
LastError: msg.HasLastError ? msg.LastError : null,
ErrorCount5Min: msg.ErrorCount5Min,
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"),
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
HostStatuses: ToHostStatuses(msg),
SkippedTags: ToSkippedTags(msg),
Diagnostics: msg.HasDiagnostics ? new Dictionary(msg.Diagnostics) : null);
}
///
/// Projects the skipped-tag report, honouring the field's own presence: null when the node said
/// the driver cannot report skips, a report when it can — including an all-zero one, which is the
/// positive statement "this driver skipped nothing" and must not be flattened back to null.
///
private static SkippedTagReport? ToSkippedTags(DriverHealth msg)
{
if (msg.SkippedTags is not { } s) return null;
var sample = new List(s.Sample.Count);
foreach (var entry in s.Sample)
{
sample.Add(new SkippedTag(entry.RawPath, entry.Reason));
}
return new SkippedTagReport(s.AuthoredCount, s.SkippedCount, sample);
}
///
/// Projects the repeated host-connectivity field, honouring the explicit presence flag: null when
/// the node said the driver has no probe, an empty list when it has one that knows no hosts.
/// An unparseable state string degrades to rather than
/// throwing — a node running a newer build that added an enum member must not be able to kill
/// central's telemetry stream, which is observability and has no business failing closed.
///
private static IReadOnlyList? ToHostStatuses(DriverHealth msg)
{
if (!msg.HasHostStatuses) return null;
var result = new List(msg.HostStatuses.Count);
foreach (var h in msg.HostStatuses)
{
result.Add(new HostConnectivityStatus(
h.HostName,
// System.Enum qualified: Google.Protobuf.WellKnownTypes also declares an Enum type.
System.Enum.TryParse(h.State, ignoreCase: true, out var state) ? state : HostState.Unknown,
h.LastChangedUtc?.ToDateTime() ?? default));
}
return result;
}
/// Projects a onto a .
/// The proto sub-message.
/// The domain record.
public static DriverResilienceStatusChanged ToResilience(DriverResilienceStatus msg)
{
ArgumentNullException.ThrowIfNull(msg);
return new DriverResilienceStatusChanged(
DriverInstanceId: msg.DriverInstanceId,
HostName: msg.HostName,
BreakerOpen: msg.BreakerOpen,
ConsecutiveFailures: msg.ConsecutiveFailures,
CurrentInFlight: msg.CurrentInFlight,
LastBreakerOpenUtc: msg.LastBreakerOpenUtc?.ToDateTime(),
LastSampledUtc: Required(msg.LastSampledUtc, "DriverResilienceStatus", "last_sampled_utc"),
PublishedUtc: Required(msg.PublishedUtc, "DriverResilienceStatus", "published_utc"));
}
///
/// Converts a required proto to a UTC ,
/// throwing a clear naming the telemetry kind + proto
/// field if an out-of-contract sender left it unset — rather than letting a bare
/// escape.
///
/// The required proto Timestamp ( when the sender omitted it).
/// The telemetry sub-message name, for the diagnostic.
/// The proto field name, for the diagnostic.
/// The Timestamp as a value.
/// is unset.
private static DateTime Required(Timestamp? ts, string kind, string field) =>
ts is null
? throw new InvalidOperationException($"telemetry {kind} missing required timestamp {field}")
: ts.ToDateTime();
}