diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index 53481b5e..a143d018 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -482,6 +482,61 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
+ ///
+ public async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Resolve the parent so the RMW happens at its OWN width (the notification path's model):
+ // the old 32-bit UDInt round-trip wrote a 16-bit parent's neighbours and overflowed on a
+ // signed parent with its sign bit set.
+ var (parent, _, symbol, status) = await DescribeSymbolAsync(parentPath, cancellationToken)
+ .ConfigureAwait(false);
+ if (status != TwinCATStatusMapper.Good) return status;
+ if (TwinCATBitAccess.CheckParent(bit, parent) is not null)
+ return TwinCATBitAccess.ApplyBitWrite(parent, ReadOnlySpan.Empty, bit, value, out _);
+
+ var (bytes, readStatus) = await ReadRawAsync(parentPath, symbol!, parent.ByteSize, cancellationToken)
+ .ConfigureAwait(false);
+ if (readStatus != TwinCATStatusMapper.Good) return readStatus;
+ var modify = TwinCATBitAccess.ApplyBitWrite(parent, bytes, bit, value, out var updated);
+ if (modify != TwinCATStatusMapper.Good) return modify;
+ return await WriteRawAsync(parentPath, symbol!, updated, cancellationToken).ConfigureAwait(false);
+ }
+ catch (AdsErrorException ex)
+ {
+ return MapAndSignal((uint)ex.ErrorCode);
+ }
+ }
+
+ ///
+ /// Writes raw bytes to a resolved symbol — the mirror of : by its index
+ /// group/offset, or through a fresh variable handle for a handle-addressed symbol (0xF005).
+ ///
+ private async Task WriteRawAsync(
+ string symbolPath, IAdsSymbol symbol, ReadOnlyMemory data, CancellationToken cancellationToken)
+ {
+ if (symbol.IndexGroup != SymbolValueByHandle)
+ {
+ var direct = await _client.WriteAsync(symbol.IndexGroup, symbol.IndexOffset, data, cancellationToken)
+ .ConfigureAwait(false);
+ return direct.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)direct.ErrorCode);
+ }
+
+ var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
+ if (handle.ErrorCode != AdsErrorCode.NoError) return MapAndSignal((uint)handle.ErrorCode);
+ try
+ {
+ var write = await _client.WriteAsync(handle.Handle, data, cancellationToken).ConfigureAwait(false);
+ return write.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)write.ErrorCode);
+ }
+ finally
+ {
+ try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
+ catch { /* best-effort release */ }
+ }
+ }
+
///
public async Task ProbeAsync(CancellationToken cancellationToken)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
index 3849edd0..1ffeb6fa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
@@ -119,6 +119,22 @@ public interface ITwinCATClient : IDisposable
object? value,
CancellationToken cancellationToken);
+ ///
+ /// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag
+ /// (MAIN.Flags.3; ADS has no symbol for WORD.N). A read-modify-write of the parent's raw
+ /// bytes at the parent's OWN resolved width (), so a 16-bit
+ /// parent is never written as 32 bits and a signed parent's sign bit is just a bit. The caller
+ /// serialises concurrent writers to the same parent; the PLC program writing the word between the
+ /// read and the write is inherent to RMW.
+ ///
+ /// The parent word's ADS symbol path (the bit path without its .N).
+ /// The bit index (0 = least significant bit of the first byte).
+ /// The bit value to write.
+ /// Cancellation token for the read and the write.
+ /// The mapped OPC UA status: Good; the parent read's status; BadOutOfRange for a bit
+ /// beyond the parent; BadTypeMismatch for a parent that is not an integer word.
+ Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken);
+
///
/// Cheap health probe — returns true when the target's AMS state is reachable.
/// Used by 's probe loop.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
index 186e1cee..277bd317 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
}
+ ///
+ /// The modify step of a bit write's read-modify-write: checks the bit against the parent
+ /// () and returns the parent's bytes — exactly parent.ByteSize of them —
+ /// with one bit set or cleared. Works on raw bytes, so the parent's width and signedness play no part.
+ ///
+ /// The parent symbol's resolved shape.
+ /// The parent's current bytes as read from the PLC.
+ /// The bit index.
+ /// The bit value.
+ /// The bytes to write back when the result is Good; otherwise empty.
+ /// Good; BadOutOfRange for a bit beyond the parent; BadTypeMismatch for a parent
+ /// that is not an integer word; BadCommunicationError for a read shorter than the parent
+ /// (writing it back would zero the missing bytes).
+ public static uint ApplyBitWrite(TwinCATPlcSymbolShape parent, ReadOnlySpan current, int bit, bool value,
+ out byte[] updated)
+ {
+ updated = [];
+ if (CheckParent(bit, parent) is not null)
+ return parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat
+ ? TwinCATStatusMapper.BadTypeMismatch
+ : TwinCATStatusMapper.BadOutOfRange;
+ if (current.Length < parent.ByteSize) return TwinCATStatusMapper.BadCommunicationError;
+
+ var bytes = current[..parent.ByteSize].ToArray();
+ var mask = (byte)(1 << (bit % 8));
+ bytes[bit / 8] = value ? (byte)(bytes[bit / 8] | mask) : (byte)(bytes[bit / 8] & ~mask);
+ updated = bytes;
+ return TwinCATStatusMapper.Good;
+ }
+
/// Reads bit of a little-endian parent word.
/// The parent word's raw bytes.
/// The bit index (0 = least significant bit of the first byte).
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 7c95a8f0..2a5d4d94 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -511,38 +511,21 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
- // BOOL-within-word write — read-modify-write of the parent word. Mirrors the bit-read
- // (AdsTwinCATClient.ReadValueAsync) which reads the parent as uint: read the parent as
- // UDInt (-> uint), flip the bit, write it back, all under a per-parent lock.
+ // BOOL-within-word write — a read-modify-write of the parent word at the parent's OWN
+ // width and on raw bytes (the client resolves the parent and checks the bit against it, the
+ // notification path's model), under a per-parent lock so concurrent bit writers to the same
+ // word don't lose each other's updates.
if (def.DataType == TwinCATDataType.Bool && parsed?.BitIndex is int bit)
{
var parentPath = (parsed with { BitIndex = null }).ToAdsSymbolName();
+ var bitValue = Convert.ToBoolean(w.Value);
var gate = _bitRmwLocks.GetOrAdd(
$"{def.DeviceHostAddress}|{parentPath}", static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- var (parentValue, readStatus) = await client.ReadValueAsync(
- parentPath, TwinCATDataType.UDInt, null, null, cancellationToken).ConfigureAwait(false);
- if (readStatus != TwinCATStatusMapper.Good)
- {
- 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);
- 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);
- results[i] = new WriteResult(writeStatus);
- }
+ results[i] = new WriteResult(await client.WriteBitAsync(
+ parentPath, bit, bitValue, cancellationToken).ConfigureAwait(false));
}
finally
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
index 2ad33e42..e31340a5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
@@ -136,6 +136,40 @@ internal class FakeTwinCATClient : ITwinCATClient
return Task.FromResult(status);
}
+ /// Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.
+ public Dictionary ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Optional parent shapes; absent = an unsigned integer word as wide as its entry.
+ public Dictionary ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Gets the log of every call.
+ public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
+ /// When set, yields between its read and its write so a missing
+ /// per-parent lock in the driver loses updates.
+ public bool YieldInBitWrite { get; set; }
+
+ /// Simulates the parent-width bit RMW with the driver's own .
+ /// The parent word's symbol path.
+ /// The bit index.
+ /// The bit value.
+ /// The cancellation token.
+ /// The write status.
+ public virtual async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct)
+ {
+ if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
+ lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value));
+ if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good)
+ return readStatus;
+ byte[] current;
+ lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : [];
+ var shape = ParentShapes.TryGetValue(parentPath, out var sh)
+ ? sh
+ : new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT");
+ if (YieldInBitWrite) await Task.Yield();
+ var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated);
+ if (status != TwinCATStatusMapper.Good) return status;
+ lock (ParentWords) ParentWords[parentPath] = updated;
+ return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good;
+ }
+
/// Simulates probing the connection status.
/// The cancellation token.
/// A task that returns the probe result.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
index 3dc22507..a5b339c8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
@@ -55,4 +55,50 @@ public sealed class TwinCATBitAccessTests
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
}
+
+ // ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ----
+
+ /// A bit write flips exactly one bit of the parent's raw bytes at the parent's width.
+ [Fact]
+ public void ApplyBitWrite_flips_one_bit_at_the_parent_width()
+ {
+ var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good);
+ set.ShouldBe(new byte[] { 0x01, 0x02 });
+ TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good);
+ cleared.ShouldBe(new byte[] { 0xF7, 0xFF });
+ }
+
+ ///
+ /// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as
+ /// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange.
+ ///
+ [Fact]
+ public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit()
+ {
+ var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good);
+ updated.ShouldBe(new byte[] { 0x01, 0x80 });
+ }
+
+ /// A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced.
+ [Fact]
+ public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have()
+ {
+ var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ none.ShouldBeEmpty();
+ var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4);
+ TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
+ }
+
+ /// A parent read shorter than the parent's width is never written back (it would zero bytes).
+ [Fact]
+ public void ApplyBitWrite_refuses_a_short_parent_buffer()
+ {
+ var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4);
+ TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none)
+ .ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+ none.ShouldBeEmpty();
+ }
}
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 c81e7b88..d7dbcc0d 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
@@ -296,23 +296,23 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
- // ---- BOOL-within-word RMW writes ----
+ // ---- BOOL-within-word RMW writes (#739 H18: at the parent's own width, on raw bytes) ----
- /// A BOOL-within-word set reads the parent word, ORs the bit, writes it back as UDInt.
+ /// A BOOL-within-word set read-modify-writes the parent word's raw bytes through WriteBitAsync.
[Fact]
- public async Task Bit_set_RMWs_parent_word_as_UDInt()
+ public async Task Bit_set_RMWs_the_parent_word_bytes()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0b0001u } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0b0001, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
- factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0b1001u);
- factory.Clients[0].WriteLog.ShouldContain(e =>
- e.symbol == "MAIN.Flags" && e.type == TwinCATDataType.UDInt && e.bit == null);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0b1001, 0, 0, 0 });
+ factory.Clients[0].BitWriteLog.ShouldBe([("MAIN.Flags", 3, true)]);
+ factory.Clients[0].WriteLog.ShouldBeEmpty(); // no 32-bit UDInt round-trip any more
}
/// A BOOL-within-word clear preserves the other bits in the parent word.
@@ -321,12 +321,12 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0xFFFFu } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0xFF, 0xFF] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
- factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0xFFF7u);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).
@@ -335,12 +335,31 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Status"] = 0u } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["GVL.Status"] = [0, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
- factory.Clients[0].Values["GVL.Status"].ShouldBe(1u << 20);
+ BitConverter.ToUInt32(factory.Clients[0].ParentWords["GVL.Status"]).ShouldBe(1u << 20);
+ }
+
+ ///
+ /// A bit beyond a 16-bit parent is refused BadOutOfRange and nothing is written — before, the hardcoded
+ /// 32-bit RMW wrote into the neighbouring bytes. No health write: a refusal is not a comms fault.
+ ///
+ [Fact]
+ public async Task Bit_beyond_a_16_bit_parent_is_refused_without_writing()
+ {
+ var (drv, factory) = NewDriver(
+ new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "MAIN.Word.20", TwinCATDataType.Bool));
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Word"] = [0, 0] } };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ factory.Clients[0].ParentWords["MAIN.Word"].ShouldBe(new byte[] { 0, 0 });
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// A failed parent read short-circuits the RMW and surfaces the read status.
@@ -351,6 +370,7 @@ public sealed class TwinCATReadWriteTests
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient
{
+ ParentWords = { ["MAIN.Flags"] = [0xAA, 0] },
ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown },
};
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -358,25 +378,24 @@ public sealed class TwinCATReadWriteTests
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
- factory.Clients[0].WriteLog.ShouldBeEmpty();
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xAA, 0 });
}
- /// 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).
+ /// A Good parent read that yields no bytes must NOT zero the word — it surfaces a Bad status
+ /// and writes nothing (treating it as 0 would clear every set bit).
[Fact]
- public async Task Bit_write_with_null_parent_value_does_not_zero_the_word()
+ public async Task Bit_write_with_no_parent_bytes_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.
+ // Do NOT seed ParentWords["MAIN.Flags"] — the fake answers the parent read Good with no bytes.
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();
+ factory.Clients[0].ParentWords.ShouldNotContainKey("MAIN.Flags");
}
/// Concurrent bit writes to the same word compose correctly (per-parent lock).
@@ -387,12 +406,12 @@ public sealed class TwinCATReadWriteTests
.Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool))
.ToArray();
var (drv, factory) = NewDriver(tags);
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0u } };
+ factory.Customise = () => new FakeTwinCATClient { YieldInBitWrite = true, ParentWords = { ["MAIN.Flags"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
- Convert.ToUInt32(factory.Clients[0].Values["MAIN.Flags"]).ShouldBe(0xFFu);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xFF, 0 });
}
}