feat(drivers): tag-set drift detection, gated on SupportsOnlineDiscovery

The second thing #516/§8.2 deliberately did not build. The original objection
stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
DiscoverAsync, so diffing discovered against authored is a tautology for them
— but it is now ENCODED rather than used as a reason not to build.

The discriminator already existed: ITagDiscovery.SupportsOnlineDiscovery is
documented as "enumerates the tag set from the live backend rather than
replaying pre-declared/authored tags", and it separates the fleet cleanly —
FOCAS, TwinCAT, MTConnect and AbCip true; the five config-echo drivers
explicitly false. The check runs only for a driver that is
SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is
nullable rather than empty-by-default on purpose: an empty collection
legitimately means "nothing authored, everything discovered is new", so
conflating the two would report total drift for a driver that never opted in.

Where the value is: AbCip and FOCAS browse a live backend but implement no
IRediscoverable, so before this they had no change signal at all. A periodic
compare is the only way to notice a PLC re-download.

A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their
authored tags into the same DiscoverAsync stream as device-derived ones, so
the browse picker can show an operator their existing tags. Discovered is
therefore always a superset of authored, and a VANISHED tag can never appear
missing — one whole direction is structurally undetectable for three of the
four. Shipping a Vanished list that is permanently empty for them would have
been the half-inert seam this whole exercise removes. So the driver declares
DiscoveryStreamIncludesAuthoredTags, the detector does not compute the
undetectable half, and the operator message says "missing-tag detection
unavailable for this driver" rather than letting the absence of a missing-tag
clause read as reassurance. MTConnect is the only driver where both directions
work — its stream is purely probe-model-derived.

Behaviour: a failed browse is not drift (an unreachable device would otherwise
report every authored tag as vanished); a truncated capture is skipped for the
same reason; an unchanged drift is reported once rather than re-stamping its
timestamp; drift that resolves clears the prompt. Interval defaults to 5
minutes — it browses a real device, and a tag set changing is an engineering
event, not a runtime one. It reuses the Stage-2 surfacing, raising the same
/hosts re-browse prompt, and never rebuilds the served address space.

Comparison logic is a pure, separately-tested type rather than actor-inline.
14 new tests. The gate was verified load-bearing by deleting the
SupportsOnlineDiscovery half: the tautology-driver test goes red, and it
asserts discovery was never invoked rather than merely "no prompt raised",
which would have passed for the wrong reason if the timer never fired.

Full suite green except the same three pre-existing fixture-gated integration
suites, identical counts.
This commit is contained in:
Joseph Doherty
2026-07-28 00:48:39 -04:00
parent 5184a2e107
commit e77c8a3569
11 changed files with 770 additions and 7 deletions
+25 -4
View File
@@ -188,10 +188,31 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
the actor. the actor.
**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql **Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
trustworthy signal because the driver asserts it deliberately. FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
only way to notice a PLC re-download.
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
legitimately means "nothing authored, so everything discovered is new".
⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
reports only what it can see and **says so** in the operator message rather than implying a clean bill of
health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
built purely from the Agent's probe model.
Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and ⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
+41
View File
@@ -530,6 +530,47 @@ half-adopting.
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass. guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
driver that simply never opted in).
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
detection is complementary there.
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
driver where both directions work** — its stream is purely probe-model-derived.
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
engineering event, not a runtime one.
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6) ### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into **The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
@@ -38,4 +38,32 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5. /// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
/// </summary> /// </summary>
bool SupportsOnlineDiscovery => false; bool SupportsOnlineDiscovery => false;
/// <summary>
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
/// two sets detects that the remote's tag set has drifted from what an operator authored.
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
/// authored refs are directly comparable to what it discovers.</para>
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
/// replays authored tags from config rather than browsing the backend, the comparison is a
/// tautology — it would report "no drift" forever and read as reassurance.</para>
/// </summary>
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
/// <summary>
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
/// shows an operator their existing tags alongside what the device offers.
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
/// as reassurance.</para>
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
/// from the Agent's probe model — so both directions are detectable.</para>
/// </summary>
bool DiscoveryStreamIncludesAuthoredTags => false;
} }
@@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary> /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true; public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
/// controller-browse leaves also use.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_declaredTags.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
/// pre-declared tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc /> /// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -517,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.</summary> /// until the node set is non-empty and stable.</summary>
public bool SupportsOnlineDiscovery => true; public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc /> /// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </remarks> /// </remarks>
public bool SupportsOnlineDiscovery => true; public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The DataItem ids the authored raw tags bind. <c>DiscoverAsync</c> emits
/// <c>FullName: dataItem.Id</c> from the Agent's probe model, so the two sets are the same
/// vocabulary.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
/// <inheritdoc />
/// <remarks>False — <c>DiscoverAsync</c> is built purely from the Agent's probe model and never
/// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
/// missing. MTConnect is currently the only driver where both drift directions are detectable.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => false;
/// <inheritdoc/> /// <inheritdoc/>
/// <remarks> /// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the /// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
@@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary> /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true; public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> is its RawPath and its driver FullName
/// (<c>DiscoverAsync</c> emits <c>FullName: tag.Name</c>), directly comparable to the browsed
/// symbols' <c>InstancePath</c>.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed ADS symbols AND re-emits every authored tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc /> /// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{ {
@@ -1,5 +1,6 @@
using Akka.Actor; using Akka.Actor;
using Akka.Event; using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -32,6 +33,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{ {
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation); public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation); public sealed record InitializeFailed(string Reason, int Generation);
@@ -121,6 +127,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.</summary> /// between connects), and dropping it in one state would lose the signal silently.</summary>
private sealed record RediscoveryRaised(RediscoveryEventArgs Args); private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
/// <see cref="QualifiesForDriftCheck"/>.</summary>
private sealed record DriftCheckTick
{
public static readonly DriftCheckTick Instance = new();
private DriftCheckTick() { }
}
public sealed class RetryConnect public sealed class RetryConnect
{ {
public static readonly RetryConnect Instance = new(); public static readonly RetryConnect Instance = new();
@@ -181,6 +196,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private DateTime? _rediscoveryNeededUtc; private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason; private string? _rediscoveryReason;
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
private readonly TimeSpan _driftCheckInterval;
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
/// which reads as "it just happened again" rather than "it is still true".</summary>
private string? _lastDriftSignature;
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>). /// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary> /// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
private IReadOnlyList<string> _desiredRefs = Array.Empty<string>(); private IReadOnlyList<string> _desiredRefs = Array.Empty<string>();
@@ -214,6 +237,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).</param> /// defaults to an empty string when not provided (e.g. in unit tests).</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability /// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param> /// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the /// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to /// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param> /// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
@@ -225,7 +250,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
IDriverCapabilityInvoker? invoker = null, IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) => TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor( Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver, driver,
reconnectInterval ?? DefaultReconnectInterval, reconnectInterval ?? DefaultReconnectInterval,
@@ -233,7 +259,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance, healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty, clusterId ?? string.Empty,
invoker, invoker,
healthPollInterval)); healthPollInterval,
driftCheckInterval));
/// <summary> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -264,6 +291,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param> /// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls; /// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param> /// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the /// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param> /// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor( public DriverInstanceActor(
@@ -273,13 +302,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null, IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null, string? clusterId = null,
IDriverCapabilityInvoker? invoker = null, IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null)
{ {
_driver = driver; _driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId; _driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty; _clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval; _reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval; _healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -350,6 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); AttachAlarmSource();
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
StartDriftCheck();
}); });
Receive<InitializeFailed>(msg => Receive<InitializeFailed>(msg =>
{ {
@@ -395,6 +427,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driverInstanceId, msg.Reason); _driverInstanceId, msg.Reason);
DetachSubscription(); DetachSubscription();
RecordFault(); RecordFault();
StopDriftCheck();
Become(Reconnecting); Become(Reconnecting);
PublishHealthSnapshot(); PublishHealthSnapshot();
}); });
@@ -402,6 +435,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{ {
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription(); DetachSubscription();
StopDriftCheck();
Become(Reconnecting); Become(Reconnecting);
PublishHealthSnapshot(); PublishHealthSnapshot();
}); });
@@ -424,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
}); });
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync); ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
Receive<DataChangeForward>(OnDataChangeForward); Receive<DataChangeForward>(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent; // Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished. // project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -518,6 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired(); ResubscribeDesired();
AttachAlarmSource(); AttachAlarmSource();
SubscribeDesiredAlarms(); SubscribeDesiredAlarms();
StartDriftCheck();
}); });
// A failure here is a no-op regardless of generation — the retry timer keeps trying the // A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state. // current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -762,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
} }
} }
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
private void StopDriftCheck() => Timers.Cancel("drift-check");
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the /// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't /// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary> /// push data-change / alarm events to an actor that has lost its driver connection.</summary>
@@ -809,6 +849,119 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoveryHandler = null; _rediscoveryHandler = null;
} }
/// <summary>
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
/// coverage.</para>
/// </summary>
private bool QualifiesForDriftCheck() =>
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
private void StartDriftCheck()
{
if (!QualifiesForDriftCheck()) return;
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
// report phantom drift on every reconnect.
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
}
/// <summary>
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
/// compare is the only way to notice a PLC program re-download.
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
/// </summary>
private async Task HandleDriftCheckAsync(DriftCheckTick _)
{
if (_driver is not ITagDiscovery discovery) return;
var authored = discovery.AuthoredDiscoveryRefs;
if (authored is null) return;
CapturedTree tree;
try
{
var builder = new CapturingAddressSpaceBuilder();
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
// block writes, reconnects and health polls behind it.
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
await _invoker.ExecuteAsync(
DriverCapability.Discover,
_driverInstanceId,
async ct => await discovery.DiscoverAsync(builder, ct),
cts.Token);
tree = builder.Build();
}
catch (Exception ex)
{
// A failed browse is not drift — the device may simply be unreachable, which the health surface
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
return;
}
if (tree.Truncated)
{
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
_log.Warning(
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
_driverInstanceId);
return;
}
var discovered = new List<string>();
CollectLeafRefs(tree.Root, discovered);
var drift = TagSetDriftDetector.Compare(
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
if (!drift.HasDrift)
{
// Recovered: the device matches the authored set again, so drop the prompt and let the next
// genuine drift re-raise with a fresh timestamp.
if (_lastDriftSignature is not null)
{
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
_driverInstanceId);
_lastDriftSignature = null;
_rediscoveryNeededUtc = null;
_rediscoveryReason = null;
PublishHealthSnapshot();
}
return;
}
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
// as "it just happened again" rather than "it is still true".
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
_lastDriftSignature = drift.Signature;
_rediscoveryNeededUtc = DateTime.UtcNow;
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
_log.Info(
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
_driverInstanceId, drift.Describe());
PublishHealthSnapshot();
}
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
private static void CollectLeafRefs(CapturedNode node, List<string> into)
{
if (node.IsLeaf) into.Add(node.Id);
foreach (var child in node.Children) CollectLeafRefs(child, into);
}
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the /// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat. /// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags /// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -984,6 +1137,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <inheritdoc /> /// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
StopDriftCheck();
DetachSubscription(); DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the // MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self. // same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
@@ -0,0 +1,92 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// The difference between what a driver <b>discovers</b> on its remote and what an operator
/// <b>authored</b> — i.e. whether the device's tag set has moved out from under the deployed config.
/// </summary>
/// <param name="Appeared">Refs the remote now offers that no authored tag binds.</param>
/// <param name="Vanished">
/// Refs authored tags bind that the remote no longer offers. <b>Always empty when
/// <paramref name="VanishedDetectable"/> is false</b> — see that parameter.
/// </param>
/// <param name="VanishedDetectable">
/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
/// ones (<c>ITagDiscovery.DiscoveryStreamIncludesAuthoredTags</c>). The discovered set then always
/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
/// <see cref="Describe"/> can say "new-on-device only" rather than implying a clean bill of health.
/// </param>
public sealed record TagSetDrift(
IReadOnlyList<string> Appeared,
IReadOnlyList<string> Vanished,
bool VanishedDetectable)
{
/// <summary>True when the two sets differ in either direction.</summary>
public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
/// <summary>
/// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
/// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
/// drift look new.
/// </summary>
public string Signature { get; } =
string.Join('', Appeared) + '' + string.Join('', Vanished);
/// <summary>An operator-facing one-liner naming what moved, suitable for a UI tooltip.</summary>
/// <returns>A short human-readable description, or "no drift" when there is none.</returns>
public string Describe()
{
if (!HasDrift) return "no drift";
var parts = new List<string>(3);
if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
// Say so rather than let the absence of a "missing" clause read as "nothing is missing".
if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
return string.Join("; ", parts);
}
/// <summary>Names at most two refs so the message stays readable when a whole PLC program is swapped.</summary>
private static string Sample(IReadOnlyList<string> refs) =>
refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
}
/// <summary>
/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
/// actor's periodic check stays cheap; kept out of <c>DriverInstanceActor</c> so it is directly testable
/// without an actor system.
/// </summary>
public static class TagSetDriftDetector
{
/// <summary>
/// Computes the two-way difference.
/// <para><b>Ordinal comparison</b>, matching how every driver keys its tag tables — a device that
/// genuinely exposes both <c>Speed</c> and <c>speed</c> must not have them collapsed.</para>
/// </summary>
/// <param name="discovered">Refs streamed by the driver's most recent discovery pass.</param>
/// <param name="authored">Refs the driver's authored tags bind (<c>ITagDiscovery.AuthoredDiscoveryRefs</c>).</param>
/// <param name="detectVanished">
/// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
/// structurally invisible. The vanished half is then not computed at all rather than computed to a
/// misleading empty — see <see cref="TagSetDrift.VanishedDetectable"/>.
/// </param>
/// <returns>The drift, which may be empty.</returns>
public static TagSetDrift Compare(
IReadOnlyCollection<string> discovered,
IReadOnlyCollection<string> authored,
bool detectVanished = true)
{
ArgumentNullException.ThrowIfNull(discovered);
ArgumentNullException.ThrowIfNull(authored);
var authoredSet = new HashSet<string>(authored, StringComparer.Ordinal);
var discoveredSet = new HashSet<string>(discovered, StringComparer.Ordinal);
var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray();
var vanished = detectVanished
? authoredSet.Where(r => !discoveredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray()
: [];
return new TagSetDrift(appeared, vanished, detectVanished);
}
}
@@ -0,0 +1,268 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored.
/// <para><b>Why this is gated rather than run for every driver.</b> Modbus, S7, AbLegacy, Sql and Mqtt
/// all stream their AUTHORED tags back out of <c>DiscoverAsync</c> rather than enumerating the device, so
/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail
/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that
/// declare <c>SupportsOnlineDiscovery</c> AND report <c>AuthoredDiscoveryRefs</c>.</para>
/// <para>The signal is advisory and shares the Stage-2 surfacing: it raises the same <c>/hosts</c>
/// re-browse prompt an <see cref="IRediscoverable"/> raise does. The served address space is never
/// rebuilt — raw tags are authored through the <c>/raw</c> browse-commit flow.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase
{
private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60);
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3);
/// <summary>A device offering a tag nothing binds raises the operator prompt, naming what appeared.</summary>
[Fact]
public void A_new_ref_on_the_device_raises_the_re_browse_prompt()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() =>
{
var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
signalled.ShouldNotBeNull();
signalled!.RediscoveryReason.ShouldNotBeNull();
signalled.RediscoveryReason!.ShouldContain("1 new on device");
signalled.RediscoveryReason!.ShouldContain("c");
},
Budget);
Sys.Stop(actor);
}
/// <summary>
/// <b>The gate that makes this feature honest.</b> A driver whose <c>DiscoverAsync</c> replays
/// authored config rather than browsing the device declares <c>SupportsOnlineDiscovery == false</c>,
/// and must never be drift-checked — for it the comparison could only ever say "no drift".
/// <para>Positive control: the driver is handed a set that WOULD read as drift, and the assertion is
/// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong
/// reason (e.g. if the timer never fired at all).</para>
/// </summary>
[Fact]
public void A_driver_that_replays_authored_config_is_never_drift_checked()
{
var driver = new BrowsableStubDriver(
discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
// Long enough for several ticks had the timer been scheduled.
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
driver.DiscoverCount.ShouldBe(
0,
"a driver that replays authored config was browsed for drift — the comparison is a tautology "
+ "for it and would report 'no drift' forever, which reads as coverage");
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
Sys.Stop(actor);
}
/// <summary>A driver that browses but does NOT report its authored refs opts out — the default. An empty
/// collection would mean "nothing authored, everything is new", so null must not be read that way.</summary>
[Fact]
public void A_driver_that_reports_no_authored_refs_is_never_drift_checked()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
driver.DiscoverCount.ShouldBe(0);
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
Sys.Stop(actor);
}
/// <summary>
/// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp
/// keeps moving reads as "it just happened again" rather than "it is still true".
/// </summary>
[Fact]
public void An_unchanged_drift_is_reported_once()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1),
Budget);
// Several more ticks pass with the same drift.
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget);
publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(
1,
"an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp");
Sys.Stop(actor);
}
/// <summary>A failed browse is not drift. A device that is merely unreachable already shows on the health
/// surface; reporting drift would turn every comms blip into "all your tags vanished".</summary>
[Fact]
public void A_failed_browse_is_not_reported_as_drift()
{
var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true };
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget);
publisher.Published.ShouldAllBe(
p => p.RediscoveryNeededUtc == null,
"a browse that threw was treated as drift — an unreachable device would report every authored "
+ "tag as vanished");
Sys.Stop(actor);
}
/// <summary>Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next
/// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one.</summary>
[Fact]
public void Drift_that_resolves_clears_the_prompt()
{
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnConnected(driver, publisher);
AwaitAssert(
() => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
Budget);
// The operator commits the new tag: authored now matches the device.
driver.SetAuthored(["a", "b"]);
AwaitAssert(
() => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(),
Budget);
Sys.Stop(actor);
}
private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher)
{
var parent = CreateTestProbe();
parent.IgnoreMessages(_ => true);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, driftCheckInterval: Tick));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
return actor;
}
/// <summary>Captured health publishes, so a test can read the rediscovery fields.</summary>
private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason);
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
{
private readonly List<HealthPublish> _published = [];
public IReadOnlyList<HealthPublish> Published
{
get { lock (_published) return _published.ToArray(); }
}
/// <inheritdoc />
public void Publish(
string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min,
DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null)
{
lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason));
}
}
/// <summary>
/// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages —
/// the shared <c>StubDriver</c> returns <c>DateTime.UtcNow</c>, which would make every publish look
/// changed and let a dedup-related bug pass unnoticed.
/// </summary>
private sealed class BrowsableStubDriver(
string[] discovered,
string[]? authored,
bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery
{
private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc);
private volatile string[]? _authored = authored;
private int _discoverCount;
/// <summary>Number of discovery passes the actor has driven — the direct read of whether the drift
/// check ran at all.</summary>
public int DiscoverCount => Volatile.Read(ref _discoverCount);
/// <summary>When true, every browse throws — models an unreachable device.</summary>
public bool DiscoverThrows { get; init; }
/// <inheritdoc />
public string DriverInstanceId => "browsable-stub-1";
/// <inheritdoc />
public string DriverType => "Stub";
/// <inheritdoc />
public bool SupportsOnlineDiscovery => supportsOnlineDiscovery;
/// <inheritdoc />
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs => _authored;
/// <summary>Models the operator committing (or removing) tags between checks.</summary>
public void SetAuthored(string[] refs) => _authored = refs;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _discoverCount);
if (DiscoverThrows) throw new IOException("device unreachable");
var folder = builder.Folder("Stub", "Stub");
foreach (var r in discovered)
{
folder.Variable(r, r, new DriverAttributeInfo(
FullName: r,
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,113 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored.
/// Kept out of the actor so the comparison rules are testable without an actor system.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagSetDriftDetectorTests
{
/// <summary>Matching sets are not drift, and must not raise an operator prompt.</summary>
[Fact]
public void Identical_sets_are_not_drift()
{
var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]);
drift.HasDrift.ShouldBeFalse();
drift.Describe().ShouldBe("no drift");
}
/// <summary>A tag the device now offers that nothing binds — the common case after a PLC re-download.</summary>
[Fact]
public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared()
{
var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]);
drift.Appeared.ShouldBe(["c"]);
drift.Vanished.ShouldBeEmpty();
drift.HasDrift.ShouldBeTrue();
}
/// <summary>An authored tag the device no longer offers — detectable only for a driver whose discovery
/// stream is purely device-derived.</summary>
[Fact]
public void An_authored_ref_missing_from_the_device_is_reported_as_vanished()
{
var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]);
drift.Vanished.ShouldBe(["b"]);
drift.Appeared.ShouldBeEmpty();
}
/// <summary>
/// <b>The honesty guard.</b> When a driver re-emits its authored tags into the discovery stream —
/// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a
/// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty
/// Vanished list; it must flag that the direction is undetectable, and say so in the description.
/// </summary>
[Fact]
public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty()
{
// "b" is authored and absent from the device — but this driver would have re-emitted it, so its
// absence here cannot occur in practice and must not be presented as a clean result either way.
var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false);
drift.VanishedDetectable.ShouldBeFalse();
drift.Vanished.ShouldBeEmpty();
drift.Appeared.ShouldBe(["c"]);
drift.Describe().ShouldContain(
"missing-tag detection unavailable",
customMessage:
"a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' "
+ "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes");
}
/// <summary>Comparison is ordinal: a device legitimately exposing both <c>Speed</c> and <c>speed</c>
/// must not have them collapsed, which would hide a genuinely new tag.</summary>
[Fact]
public void Comparison_is_case_sensitive()
{
var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]);
drift.Appeared.ShouldBe(["speed"]);
}
/// <summary>The signature identifies a drift so an UNCHANGED one is not re-announced every check.
/// Enumeration order must not make an identical drift look new.</summary>
[Fact]
public void Signature_is_stable_across_enumeration_order()
{
var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]);
a.Signature.ShouldBe(b.Signature);
}
/// <summary>A different drift must get a different signature, or the second one is never reported.</summary>
[Fact]
public void Signature_changes_when_the_drift_changes()
{
var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]);
var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
a.Signature.ShouldNotBe(b.Signature);
}
/// <summary>The description names a couple of refs and then counts, so swapping a whole PLC program
/// yields a readable message rather than a wall of tag names.</summary>
[Fact]
public void Describe_samples_rather_than_listing_everything()
{
var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray();
var drift = TagSetDriftDetector.Compare(discovered, []);
var text = drift.Describe();
text.ShouldContain("50 new on device");
text.ShouldContain("+48 more");
text.Length.ShouldBeLessThan(120);
}
}