Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Grpc/TelemetryProtoMapNode.cs
T
Joseph Doherty 09a401b881 feat(drivers): consume IRediscoverable as an operator re-browse prompt (§8.2, #518)
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.
2026-07-27 18:47:34 -04:00

176 lines
7.0 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;
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)
{
// 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<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;
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;
}
/// <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);
}
}