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 3d5747ba..4fa3eee0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -169,6 +169,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery _devices.Clear(); _tagsByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan + // their SemaphoreSlim instances (each leaks a wait handle once contended). + foreach (var sem in _bitRmwLocks.Values) sem.Dispose(); + _bitRmwLocks.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -319,9 +323,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery { results[i] = new WriteResult(readStatus); } + else if (parentValue is null) + { + // Good status but no value — treating null as 0 would write a zeroed + // word and clear every bit set on the device. Surface a Bad status and + // write nothing rather than corrupt the parent word. + results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError); + } else { - var word = Convert.ToUInt32(parentValue ?? 0u); + var word = Convert.ToUInt32(parentValue); var updated = Convert.ToBoolean(w.Value) ? word | (1u << bit) : word & ~(1u << bit); var writeStatus = await client.WriteValueAsync( parentPath, TwinCATDataType.UDInt, null, updated, cancellationToken).ConfigureAwait(false); @@ -719,6 +730,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery _devices.Clear(); _tagsByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the + // SemaphoreSlim instances aren't orphaned on disposal. + foreach (var sem in _bitRmwLocks.Values) sem.Dispose(); + _bitRmwLocks.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs index aef00412..6c08a226 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs @@ -332,6 +332,24 @@ public sealed class TwinCATReadWriteTests factory.Clients[0].WriteLog.ShouldBeEmpty(); } + /// A Good parent read that yields a null value must NOT zero the word — it surfaces + /// a Bad status and writes nothing (treating null as 0 would clear every set bit). + [Fact] + public async Task Bit_write_with_null_parent_value_does_not_zero_the_word() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool)); + // Do NOT seed Values["MAIN.Flags"] — the fake returns (null, Good) for the parent read. + factory.Customise = () => new FakeTwinCATClient(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); + // No parent write attempted — the word is left untouched on the device. + factory.Clients[0].WriteLog.ShouldBeEmpty(); + } + /// Concurrent bit writes to the same word compose correctly (per-parent lock). [Fact] public async Task Concurrent_bit_writes_to_same_word_compose_correctly()