Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.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

47 lines
2.5 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// <summary>
/// Snapshot of a single driver instance's health, published to the
/// <c>driver-health</c> DistributedPubSub topic whenever a driver actor
/// transitions state or logs an error. Consumed by
/// <c>DriverStatusSignalRBridge</c> in the AdminUI for live status push.
/// </summary>
/// <param name="ClusterId">Cluster this driver belongs to.</param>
/// <param name="DriverInstanceId">Globally-unique driver instance ID.</param>
/// <param name="State">DriverState enum as string (Healthy, Faulted, Reconnecting, etc.).</param>
/// <param name="LastSuccessfulReadUtc">Most recent successful equipment read; null if never.</param>
/// <param name="LastError">Latest error message; null when none.</param>
/// <param name="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
/// <param name="PublishedUtc">Timestamp this snapshot was published.</param>
/// <param name="RediscoveryNeededUtc">
/// When the driver last raised <c>IRediscoverable.OnRediscoveryNeeded</c> — i.e. it observed that the
/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect
/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not
/// implement <c>IRediscoverable</c>.
/// <para><b>Advisory only.</b> The served address space is NOT rebuilt in response — raw tags are
/// authored explicitly through the <c>/raw</c> browse-commit flow. This is a prompt for an operator
/// to re-browse the device and commit whatever changed.</para>
/// </param>
/// <param name="RediscoveryReason">
/// The driver-supplied reason string from the same event (e.g. <c>"deploy-time-changed"</c>), shown
/// to the operator alongside the prompt. Null when <paramref name="RediscoveryNeededUtc"/> is null.
/// </param>
public sealed record DriverHealthChanged(
string ClusterId,
string DriverInstanceId,
string State,
DateTime? LastSuccessfulReadUtc,
string? LastError,
int ErrorCount5Min,
DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null,
string? RediscoveryReason = null)
{
/// <summary>
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
/// <c>DriverStatusSignalRBridge</c> reference this single constant so renames can't
/// silently desynchronise publisher and subscriber.
/// </summary>
public const string TopicName = "driver-health";
}