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 3dc69b81..12fda5e7 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
@@ -87,7 +87,7 @@
{
"id": "B3.4",
"subject": "B3: wire onError->health + 30s backoffCap into TwinCAT/FOCAS engine ctors (tests first per driver) \u2014 STAB-9",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"B3.2"
]
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index e964ca94..4e64e2bd 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -72,7 +72,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
- OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
+ onError: HandlePollError,
+ backoffCap: PollBackoffCap);
+ }
+
+ /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).
+ private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
+
+ ///
+ /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
+ /// degrades to preserving LastSuccessfulRead.
+ /// Never downgrades a state (a stronger, config-level signal).
+ ///
+ /// The exception caught by the poll engine.
+ internal void HandlePollError(Exception ex)
+ {
+ _logger.LogWarning(ex, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
+ var current = Volatile.Read(ref _health);
+ if (current.State != DriverState.Faulted)
+ Volatile.Write(ref _health,
+ new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
///
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 4748f844..1fd23a1b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -69,7 +69,26 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
- OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
+ onError: HandlePollError,
+ backoffCap: PollBackoffCap);
+ }
+
+ /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).
+ private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
+
+ ///
+ /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
+ /// degrades to preserving LastSuccessfulRead.
+ /// Never downgrades a state (a stronger, config-level signal).
+ ///
+ /// The exception caught by the poll engine.
+ internal void HandlePollError(Exception ex)
+ {
+ _logger.LogWarning(ex, "TwinCAT poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
+ var current = _health;
+ if (current.State != DriverState.Faulted)
+ _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
}
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
new file mode 100644
index 00000000..84a9da76
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
@@ -0,0 +1,58 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// 05/STAB-9 — the poll engine's onError sink must be wired to the driver health
+/// surface: a poll-loop reader failure degrades health while preserving
+/// , and must never downgrade a pre-existing
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class FocasPollErrorHealthTests
+{
+ /// A poll reader failure degrades health, preserving the last successful read timestamp.
+ [Fact]
+ public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
+ {
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-1", new FakeFocasClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var before = drv.GetHealth();
+ before.State.ShouldBe(DriverState.Healthy);
+ before.LastSuccessfulRead.ShouldNotBeNull();
+
+ drv.HandlePollError(new InvalidOperationException("poll boom"));
+
+ var after = drv.GetHealth();
+ after.State.ShouldBe(DriverState.Degraded);
+ after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
+ after.LastError.ShouldContain("poll boom");
+ }
+
+ /// A poll reader failure never downgrades a pre-existing Faulted state.
+ [Fact]
+ public async Task PollReaderFailure_NeverDowngradesFaulted()
+ {
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-1", new FakeFocasClientFactory());
+
+ await Should.ThrowAsync(() => drv.InitializeAsync("{}", CancellationToken.None));
+ drv.GetHealth().State.ShouldBe(DriverState.Faulted);
+
+ drv.HandlePollError(new InvalidOperationException("poll boom"));
+
+ drv.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs
new file mode 100644
index 00000000..1f97f399
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATPollErrorHealthTests.cs
@@ -0,0 +1,61 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// 05/STAB-9 — the poll engine's onError sink must be wired to the driver health
+/// surface: a poll-loop reader failure degrades health while preserving
+/// , and must never downgrade a pre-existing
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATPollErrorHealthTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ /// A poll reader failure degrades health, preserving the last successful read timestamp.
+ [Fact]
+ public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ EnableControllerBrowse = false,
+ }, "twincat-1", new FakeTwinCATClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var before = drv.GetHealth();
+ before.State.ShouldBe(DriverState.Healthy);
+ before.LastSuccessfulRead.ShouldNotBeNull();
+
+ drv.HandlePollError(new InvalidOperationException("poll boom"));
+
+ var after = drv.GetHealth();
+ after.State.ShouldBe(DriverState.Degraded);
+ after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
+ after.LastError.ShouldContain("poll boom");
+ }
+
+ /// A poll reader failure never downgrades a pre-existing Faulted state.
+ [Fact]
+ public async Task PollReaderFailure_NeverDowngradesFaulted()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("not-a-valid-address")],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ EnableControllerBrowse = false,
+ }, "twincat-1", new FakeTwinCATClientFactory());
+
+ await Should.ThrowAsync(() => drv.InitializeAsync("{}", CancellationToken.None));
+ drv.GetHealth().State.ShouldBe(DriverState.Faulted);
+
+ drv.HandlePollError(new InvalidOperationException("poll boom"));
+
+ drv.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+}