feat(mesh-phase5): node-side TelemetryStreamService (hub -> server-streaming)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 15:48:06 -04:00
parent 909d75357b
commit e742fee452
6 changed files with 601 additions and 0 deletions
@@ -0,0 +1,167 @@
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;
/// <summary>
/// Node-side projection of a domain <see cref="TelemetryItem"/> onto its wire
/// <see cref="TelemetryEvent"/> envelope (per-cluster mesh Phase 5). The exact reverse of
/// <c>telemetry.proto</c>: it transcribes every domain field onto the matching proto field and
/// selects the envelope's <c>oneof</c> arm from the <see cref="TelemetryItem"/> subtype.
/// </summary>
/// <remarks>
/// <para>
/// <b>DateTime → Timestamp is Kind-defensive.</b> <see cref="Timestamp.FromDateTime"/> throws
/// unless the source <see cref="DateTime.Kind"/> is <see cref="DateTimeKind.Utc"/>. Producers
/// use <see cref="DateTime.UtcNow"/> today, but a stray <see cref="DateTimeKind.Local"/> or
/// <see cref="DateTimeKind.Unspecified"/> 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).
/// </para>
/// <para>
/// <b>Nullable presence is preserved.</b> A null nullable-<c>DateTime</c> leaves the proto
/// Timestamp unset (absent == null per the contract); a null <c>optional string</c> leaves the
/// proto field unset so the generated <c>Has…</c> presence distinguishes null from "".
/// </para>
/// </remarks>
public static class TelemetryProtoMapNode
{
/// <summary>Projects <paramref name="item"/> onto a wire <see cref="TelemetryEvent"/>.</summary>
/// <param name="item">The domain telemetry item to project.</param>
/// <param name="correlationId">Stream correlation id echoed on every envelope.</param>
/// <returns>The wire envelope with the matching <c>oneof</c> arm populated.</returns>
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)
{
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<string>());
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;
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;
}
/// <summary>
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
/// already-UTC (the producer contract — <see cref="DateTime.UtcNow"/> — with the wall-clock
/// ticks preserved so the instant round-trips).
/// </summary>
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);
}
}