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}"); } }