c2bdb7d5de
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
213 lines
11 KiB
C#
213 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Central-side projection of a wire <see cref="TelemetryEvent"/> envelope back onto its domain
|
|
/// record (per-cluster mesh Phase 5). The exact reverse of <c>TelemetryProtoMapNode</c>: it
|
|
/// transcribes every proto field onto the matching domain field and selects the domain record from
|
|
/// the envelope's <c>oneof</c> arm.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Timestamp presence is honoured.</b> A proto <c>google.protobuf.Timestamp</c> field is a
|
|
/// message: when the node left it unset (a null nullable-<c>DateTime</c> on the domain side) the
|
|
/// generated property is <see langword="null"/>, so the nullable domain fields map via
|
|
/// <c>?.ToDateTime()</c> and an absent Timestamp becomes <see langword="null"/>. The
|
|
/// non-nullable domain fields (published / sampled / transition timestamps) are always set by
|
|
/// the node mapper, so they map via <see cref="Required"/> (which yields a
|
|
/// <see cref="DateTimeKind.Utc"/> value).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A required Timestamp is defended, not assumed.</b> Across a mixed-version fleet an
|
|
/// out-of-contract sender could leave a required Timestamp unset. <see cref="Required"/> throws
|
|
/// a clear <see cref="InvalidOperationException"/> naming the kind + field instead of letting a
|
|
/// bare <see cref="NullReferenceException"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>optional-string presence is honoured.</b> A proto3 <c>optional string</c> that the node
|
|
/// left unset reads back as <see langword="null"/> (via the generated <c>Has…</c> presence),
|
|
/// distinguishing null from the empty string; likewise the <c>optional bool</c>
|
|
/// <c>historize_to_aveva</c> round-trips its three states (null / true / false).
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class TelemetryProtoMapCentral
|
|
{
|
|
/// <summary>
|
|
/// Projects a wire <see cref="TelemetryEvent"/> onto its domain record, selecting the type from
|
|
/// the populated <c>oneof</c> arm. This switch is the coverage guard: every
|
|
/// <see cref="TelemetryProtoContract.HandledCases"/> value has an arm here, and an unset (or a
|
|
/// future unmapped) case throws <see cref="NotSupportedException"/> so adding a fifth oneof case
|
|
/// without a converter fails the coverage test.
|
|
/// </summary>
|
|
/// <param name="evt">The wire envelope to project.</param>
|
|
/// <returns>The domain record for the populated arm.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="evt"/> is null.</exception>
|
|
/// <exception cref="NotSupportedException">The envelope carries no handled <c>oneof</c> arm.</exception>
|
|
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})."),
|
|
};
|
|
}
|
|
|
|
/// <summary>Projects an <see cref="AlarmTransition"/> onto an <see cref="AlarmTransitionEvent"/>.</summary>
|
|
/// <param name="msg">The proto sub-message.</param>
|
|
/// <returns>The domain record.</returns>
|
|
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());
|
|
}
|
|
|
|
/// <summary>Projects a <see cref="ScriptLog"/> onto a <see cref="ScriptLogEntry"/>.</summary>
|
|
/// <param name="msg">The proto sub-message.</param>
|
|
/// <returns>The domain record.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Projects a <see cref="DriverHealth"/> onto a <see cref="DriverHealthChanged"/>.</summary>
|
|
/// <param name="msg">The proto sub-message.</param>
|
|
/// <returns>The domain record.</returns>
|
|
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<string, string>(msg.Diagnostics) : null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static SkippedTagReport? ToSkippedTags(DriverHealth msg)
|
|
{
|
|
if (msg.SkippedTags is not { } s) return null;
|
|
|
|
var sample = new List<SkippedTag>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>An unparseable state string degrades to <see cref="HostState.Unknown"/> 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.</para>
|
|
/// </summary>
|
|
private static IReadOnlyList<HostConnectivityStatus>? ToHostStatuses(DriverHealth msg)
|
|
{
|
|
if (!msg.HasHostStatuses) return null;
|
|
|
|
var result = new List<HostConnectivityStatus>(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<HostState>(h.State, ignoreCase: true, out var state) ? state : HostState.Unknown,
|
|
h.LastChangedUtc?.ToDateTime() ?? default));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Projects a <see cref="DriverResilienceStatus"/> onto a <see cref="DriverResilienceStatusChanged"/>.</summary>
|
|
/// <param name="msg">The proto sub-message.</param>
|
|
/// <returns>The domain record.</returns>
|
|
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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a <b>required</b> proto <see cref="Timestamp"/> to a UTC <see cref="DateTime"/>,
|
|
/// throwing a clear <see cref="InvalidOperationException"/> naming the telemetry kind + proto
|
|
/// field if an out-of-contract sender left it unset — rather than letting a bare
|
|
/// <see cref="NullReferenceException"/> escape.
|
|
/// </summary>
|
|
/// <param name="ts">The required proto Timestamp (<see langword="null"/> when the sender omitted it).</param>
|
|
/// <param name="kind">The telemetry sub-message name, for the diagnostic.</param>
|
|
/// <param name="field">The proto field name, for the diagnostic.</param>
|
|
/// <returns>The Timestamp as a <see cref="DateTimeKind.Utc"/> value.</returns>
|
|
/// <exception cref="InvalidOperationException"><paramref name="ts"/> is unset.</exception>
|
|
private static DateTime Required(Timestamp? ts, string kind, string field) =>
|
|
ts is null
|
|
? throw new InvalidOperationException($"telemetry {kind} missing required timestamp {field}")
|
|
: ts.ToDateTime();
|
|
}
|