diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 33376fa..311bca0 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -329,9 +329,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
+ var parsedPath = AbCipTagPath.TryParse(def.TagPath);
+
+ // BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
+ // runtime. Dispatching here keeps the normal EncodeValue path clean; the
+ // per-parent lock prevents two concurrent bit writes to the same DINT from
+ // losing one another's update.
+ if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
+ {
+ results[i] = new WriteResult(
+ await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
+ .ConfigureAwait(false));
+ if (results[i].StatusCode == AbCipStatusMapper.Good)
+ _health = new DriverHealth(DriverState.Healthy, now, null);
+ continue;
+ }
+
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
- var tagPath = AbCipTagPath.TryParse(def.TagPath);
- runtime.EncodeValue(def.DataType, tagPath?.BitIndex, w.Value);
+ runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
var status = runtime.GetStatus();
@@ -374,6 +389,74 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
+ ///
+ /// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
+ /// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
+ /// writers against the same parent via a per-parent .
+ /// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
+ ///
+ private async Task WriteBitInDIntAsync(
+ DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
+ {
+ var parentPath = bitPath with { BitIndex = null };
+ var parentName = parentPath.ToLibplctagName();
+
+ var rmwLock = device.GetRmwLock(parentName);
+ await rmwLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
+ await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
+ var readStatus = parentRuntime.GetStatus();
+ if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
+
+ var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
+ var updated = Convert.ToBoolean(value)
+ ? current | (1 << bit)
+ : current & ~(1 << bit);
+
+ parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
+ await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
+ var writeStatus = parentRuntime.GetStatus();
+ return writeStatus == 0
+ ? AbCipStatusMapper.Good
+ : AbCipStatusMapper.MapLibplctagStatus(writeStatus);
+ }
+ finally
+ {
+ rmwLock.Release();
+ }
+ }
+
+ ///
+ /// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
+ /// so repeated bit writes against the same DINT share one handle.
+ ///
+ private async Task EnsureParentRuntimeAsync(
+ DeviceState device, string parentTagName, CancellationToken ct)
+ {
+ if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
+
+ var runtime = _tagFactory.Create(new AbCipTagCreateParams(
+ Gateway: device.ParsedAddress.Gateway,
+ Port: device.ParsedAddress.Port,
+ CipPath: device.ParsedAddress.CipPath,
+ LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
+ TagName: parentTagName,
+ Timeout: _options.Timeout));
+ try
+ {
+ await runtime.InitializeAsync(ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ runtime.Dispose();
+ throw;
+ }
+ device.ParentRuntimes[parentTagName] = runtime;
+ return runtime;
+ }
+
///
/// Idempotently materialise the runtime handle for a tag definition. First call creates
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
@@ -572,12 +655,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public Dictionary Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Parent-DINT runtimes created on-demand by
+ /// for BOOL-within-DINT RMW writes. Separate from because a
+ /// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
+ /// parent ("Motor.Flags") used to do the read + write.
+ ///
+ public Dictionary ParentRuntimes { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new();
+
+ public SemaphoreSlim GetRmwLock(string parentTagName) =>
+ _rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
+
public void DisposeHandles()
{
foreach (var h in TagHandles.Values) h.Dispose();
TagHandles.Clear();
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
+ foreach (var r in ParentRuntimes.Values) r.Dispose();
+ ParentRuntimes.Clear();
}
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 414de55..891d27d 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -58,13 +58,14 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
switch (type)
{
case AbCipDataType.Bool:
- if (bitIndex is int bit)
+ if (bitIndex is int)
{
- // BOOL-within-DINT writes require read-modify-write on the parent DINT.
- // Deferred to a follow-up PR — matches the Modbus BitInRegister pattern at
- // ModbusDriver.cs:640.
+ // BOOL-within-DINT writes are routed at the driver level (AbCipDriver.
+ // WriteBitInDIntAsync) via a parallel parent-DINT runtime so the RMW stays
+ // serialised. If one reaches here it means the driver dispatch was bypassed —
+ // throw so the error surfaces loudly rather than clobbering the whole DINT.
throw new NotSupportedException(
- "BOOL-within-DINT writes require read-modify-write; not implemented in PR 4.");
+ "BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not LibplctagTagRuntime.");
}
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
break;
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 10b4c9d..3145e58 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -186,8 +186,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
- var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
var parsed = AbLegacyAddress.TryParse(def.Address);
+
+ // PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
+ // parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
+ // concurrent bit writers. Applies to N-file bit-in-word (N7:0/3) + B-file bits
+ // (B3:0/0). T/C/R sub-elements don't hit this path because they're not Bit typed.
+ if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit
+ && parsed.FileLetter is not "B" and not "I" and not "O")
+ {
+ results[i] = new WriteResult(
+ await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
+ continue;
+ }
+
+ var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
@@ -331,6 +344,70 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
+ ///
+ /// Read-modify-write one bit within a PCCC N-file word. Strips the /N bit suffix to
+ /// form the parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime
+ /// typed as Int16, serialises concurrent bit writers against the same parent via a
+ /// per-parent .
+ ///
+ private async Task WriteBitInWordAsync(
+ AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
+ {
+ var parentAddress = bitAddress with { BitIndex = null };
+ var parentName = parentAddress.ToLibplctagName();
+
+ var rmwLock = device.GetRmwLock(parentName);
+ await rmwLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
+ await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
+ var readStatus = parentRuntime.GetStatus();
+ if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
+
+ var current = Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, bitIndex: null) ?? 0);
+ var updated = Convert.ToBoolean(value)
+ ? current | (1 << bit)
+ : current & ~(1 << bit);
+
+ parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
+ await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
+ var writeStatus = parentRuntime.GetStatus();
+ return writeStatus == 0
+ ? AbLegacyStatusMapper.Good
+ : AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
+ }
+ finally
+ {
+ rmwLock.Release();
+ }
+ }
+
+ private async Task EnsureParentRuntimeAsync(
+ AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
+ {
+ if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
+
+ var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
+ Gateway: device.ParsedAddress.Gateway,
+ Port: device.ParsedAddress.Port,
+ CipPath: device.ParsedAddress.CipPath,
+ LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
+ TagName: parentName,
+ Timeout: _options.Timeout));
+ try
+ {
+ await runtime.InitializeAsync(ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ runtime.Dispose();
+ throw;
+ }
+ device.ParentRuntimes[parentName] = runtime;
+ return runtime;
+ }
+
private async Task EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -374,6 +451,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public Dictionary Runtimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Parent-word runtimes for bit-within-word RMW writes (task #181). Keyed by the
+ /// parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
+ /// single parent runtime for N7:0.
+ ///
+ public Dictionary ParentRuntimes { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new();
+
+ public SemaphoreSlim GetRmwLock(string parentName) =>
+ _rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));
+
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
@@ -384,6 +474,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
foreach (var r in Runtimes.Values) r.Dispose();
Runtimes.Clear();
+ foreach (var r in ParentRuntimes.Values) r.Dispose();
+ ParentRuntimes.Clear();
}
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
index b05b1fd..3f8e1aa 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -51,8 +51,12 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
case AbLegacyDataType.Bit:
if (bitIndex is int)
+ // Bit-within-word writes are routed at the driver level
+ // (AbLegacyDriver.WriteBitInWordAsync) via a parallel parent-word runtime —
+ // this branch only fires if dispatch was bypassed. Throw loudly rather than
+ // silently clobbering the whole word.
throw new NotSupportedException(
- "Bit-within-word writes require read-modify-write; tracked in task #181.");
+ "Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0);
break;
case AbLegacyDataType.Int:
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs
new file mode 100644
index 0000000..7077a2a
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs
@@ -0,0 +1,152 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AbCipBoolInDIntRmwTests
+{
+ ///
+ /// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
+ /// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
+ /// which the driver's WriteBitInDIntAsync reads + writes.
+ ///
+ private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
+ {
+ // Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
+ }
+
+ [Fact]
+ public async Task Bit_set_reads_parent_ORs_bit_writes_back()
+ {
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new ParentDintFake(p) { Value = 0b0001 },
+ };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags =
+ [
+ new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Flag3", true)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+
+ // Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
+ factory.Tags.ShouldContainKey("Motor.Flags");
+ factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
+ factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
+ factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Bit_clear_preserves_other_bits()
+ {
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
+ };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
+
+ var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
+ (updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
+ (updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
+ }
+
+ [Fact]
+ public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
+ {
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new ParentDintFake(p) { Value = 0 },
+ };
+ var tags = Enumerable.Range(0, 8)
+ .Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
+ .ToArray();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = tags,
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
+ drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
+
+ Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
+ }
+
+ [Fact]
+ public async Task Bit_writes_to_different_parents_each_get_own_runtime()
+ {
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new ParentDintFake(p) { Value = 0 },
+ };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags =
+ [
+ new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
+ new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
+ await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
+
+ factory.Tags.ShouldContainKey("Motor1.Flags");
+ factory.Tags.ShouldContainKey("Motor2.Flags");
+ }
+
+ [Fact]
+ public async Task Repeat_bit_writes_reuse_one_parent_runtime()
+ {
+ var factory = new FakeAbCipTagFactory
+ {
+ Customise = p => new ParentDintFake(p) { Value = 0 },
+ };
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags =
+ [
+ new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
+ new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
+ await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
+
+ // Three factory invocations: two bit-selector tags (never used for writes, but the
+ // driver may create them opportunistically) + one shared parent. Assert the parent was
+ // init'd exactly once + used for both writes.
+ factory.Tags["Flags"].InitializeCount.ShouldBe(1);
+ factory.Tags["Flags"].WriteCount.ShouldBe(2);
+ Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs
index d6fc72c..28f53b1 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs
@@ -60,9 +60,12 @@ public sealed class AbCipDriverWriteTests
}
[Fact]
- public async Task Bit_in_dint_write_returns_BadNotSupported()
+ public async Task Bit_in_dint_write_now_succeeds_via_RMW()
{
- var factory = new FakeAbCipTagFactory { Customise = p => new ThrowingBoolBitFake(p) };
+ // Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
+ // WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
+ // than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
+ var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
@@ -73,7 +76,7 @@ public sealed class AbCipDriverWriteTests
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
- results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
+ results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
new file mode 100644
index 0000000..8eb6d1c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
@@ -0,0 +1,104 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AbLegacyBitRmwTests
+{
+ [Fact]
+ public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync(
+ [new WriteRequest("Flag3", true)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
+ Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
+ }
+
+ [Fact]
+ public async Task Bit_clear_preserves_other_bits_in_N_file_word()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
+
+ Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
+ }
+
+ [Fact]
+ public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
+ };
+ var tags = Enumerable.Range(0, 8)
+ .Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
+ .ToArray();
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = tags,
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
+ drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
+
+ Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
+ }
+
+ [Fact]
+ public async Task Repeat_bit_writes_reuse_parent_runtime()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags =
+ [
+ new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
+ new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
+ ],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
+ await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
+
+ factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
+ factory.Tags["N7:0"].WriteCount.ShouldBe(2);
+ Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs
index 1997c6f..b71ade6 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs
@@ -157,9 +157,12 @@ public sealed class AbLegacyReadWriteTests
}
[Fact]
- public async Task Bit_within_word_write_rejected_as_BadNotSupported()
+ public async Task Bit_within_word_write_now_succeeds_via_RMW()
{
- var factory = new FakeAbLegacyTagFactory { Customise = p => new RmwThrowingFake(p) };
+ // Task #181 pass 2 lifted this gap — N-file bit writes now go through
+ // WriteBitInWordAsync + a parallel parent-word runtime, so the status is Good rather
+ // than BadNotSupported. Full RMW semantics covered by AbLegacyBitRmwTests.
+ var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
@@ -170,7 +173,7 @@ public sealed class AbLegacyReadWriteTests
var results = await drv.WriteAsync(
[new WriteRequest("Bit3", true)], CancellationToken.None);
- results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
}
[Fact]