diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
index 1f102bf1..93205f6b 100644
--- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
+++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
@@ -192,7 +192,7 @@
{
"id": "B6.2",
"subject": "B6: NativeRegistration HasLiveHandle/MarkHandleDead/OnUnavailable + replay-catch Bad-quality emit + health degrade \u2014 STAB-16",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"B6.1"
]
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index a538dc16..e0a7ffe0 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -493,9 +493,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var reg = new NativeRegistration(
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
def.DataType, bitIndex, publishingInterval,
- (_, value) => OnDataChange?.Invoke(this,
+ onChange: (_, value) => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
- value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))));
+ value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
+ // 05/STAB-16: when the handle goes dead (a failed replay) push a Bad snapshot so
+ // subscribers see the tag go Bad instead of a frozen-Good-stale value.
+ onUnavailable: () => OnDataChange?.Invoke(this,
+ new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
+ null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow))));
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
registrations.Add(reg);
@@ -593,8 +598,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// Value-change callback (closes over the owning subscription handle + reference).
public Action OnChange { get; }
+ ///
+ /// Bad-quality callback (05/STAB-16). Invoked when this registration's handle goes dead
+ /// (a failed replay) so subscribers observe the tag go Bad instead of frozen-Good-stale —
+ /// the shape has no quality channel. Closes over the same
+ /// (handle, reference) pair as .
+ ///
+ public Action OnUnavailable { get; }
+
private ITwinCATNotificationHandle? _handle;
+ ///
+ /// True while this registration holds a live notification handle. False after a failed
+ /// replay marks it dead (05/STAB-16) — the probe-tick retry re-registers such handles.
+ ///
+ public bool HasLiveHandle => Volatile.Read(ref _handle) is not null;
+
/// Initializes a new instance of the class.
/// Globally-unique registration id (registry key on the device).
/// The device this notification is registered against.
@@ -604,8 +623,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// Bit index for a BOOL-within-word symbol; null otherwise.
/// Requested notification interval.
/// Value-change callback (closes over the owning subscription handle + reference).
+ /// Bad-quality callback fired when the handle goes dead (05/STAB-16).
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
- TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action onChange)
+ TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action onChange,
+ Action onUnavailable)
{
Id = id;
Device = device;
@@ -615,6 +636,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
BitIndex = bitIndex;
Interval = interval;
OnChange = onChange;
+ OnUnavailable = onUnavailable;
}
/// Installs a freshly-registered handle and returns the previous one (null on first set).
@@ -623,6 +645,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public ITwinCATNotificationHandle? SwapHandle(ITwinCATNotificationHandle newHandle) =>
Interlocked.Exchange(ref _handle, newHandle);
+ ///
+ /// Atomically clears the live handle and returns the previous one (05/STAB-16). The caller
+ /// disposes the returned (already-dead) handle best-effort. After this
+ /// is false until a retry re-registers.
+ ///
+ /// The previous handle, or null.
+ public ITwinCATNotificationHandle? MarkHandleDead() =>
+ Interlocked.Exchange(ref _handle, null);
+
/// Removes this registration from its device and disposes its live handle.
public void Dispose()
{
@@ -851,6 +882,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
"TwinCAT driver '{DriverInstanceId}' failed to replay native notification " +
"'{Symbol}' on device '{HostAddress}' after reconnect",
_driverInstanceId, reg.SymbolName, device.Options.HostAddress);
+
+ // 05/STAB-16: the intent has no live handle now — mark it dead so the probe-tick
+ // retry re-registers it, surface Bad quality to subscribers (the OnChange shape has
+ // no quality channel), and degrade health preserving the last successful read.
+ var dead = reg.MarkHandleDead();
+ try { dead?.Dispose(); } catch { /* already-dead handle from the disposed client */ }
+ try { reg.OnUnavailable(); } catch { /* never let a subscriber callback abort recovery of the rest */ }
+ if (_health.State != DriverState.Faulted)
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
+ $"native-notification replay failed for '{reg.SymbolName}' on {device.Options.HostAddress}");
}
}