09a401b881
Nine drivers raise IRediscoverable.OnRediscoveryNeeded when they observe that a remote's tag set may have changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug rebirth. Nothing in src/ subscribed, so every raise went into the void. Drivers even wrap the invoke in try/catch for "subscriber threw"; there has never been a subscriber. DriverInstanceActor now attaches the event in PreStart and detaches in PostStop, mirroring AttachAlarmSource/DetachAlarmSource. Attach is per-actor, not per-connect: the raise has no connection affinity and can land while the driver is between connects. The detach is load-bearing rather than tidy — the IDriver instance outlives the actor when the host respawns a child around the same driver object, so a missing -= accumulates one handler per respawn, each holding a dead Self. The signal rides the existing driver-health snapshot to /hosts as a "re-browse" chip. It is ADVISORY: the served address space is deliberately not rebuilt, because v3 authors raw tags explicitly through the /raw browse-commit flow and a runtime graft would materialise nodes no operator approved and no deployment artifact records. Two things worth knowing: - PublishHealthSnapshot dedups on a health fingerprint, and a rediscovery raise on an otherwise-healthy driver changes none of the four fields it hashed. Without adding the timestamp to that tuple the dedup swallows the exact publish carrying the signal. Reverting that one line turns 3 of the 5 new tests red, which is how it was confirmed rather than assumed. - The new test stub implements IDriver from scratch instead of deriving from the shared StubDriver, whose GetHealth() returns DateTime.UtcNow — its fingerprint differs on every call, so the dedup never engages and the dedup test would have passed vacuously either way. The planned discovery-result drift detector was NOT built. Modbus, S7, MQTT, AbLegacy and Sql all echo authored config from DiscoverAsync rather than browsing the device, so a discovered-vs-authored diff is permanently empty for them — it would have been another plausible-looking inert seam, which is the class this audit exists to remove. IRediscoverable is the trustworthy signal because the driver asserts it deliberately. DriverHealthChanged, IDriverHealthPublisher.Publish and HostsDriverRow gain defaulted optional parameters, so no existing call site changed. telemetry.proto gains fields 8/9 and both Phase-5 mappers carry them. Runtime.Tests 512 passed / 31 skipped.
160 lines
8.4 KiB
C#
160 lines
8.4 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;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <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();
|
|
}
|