From 00a428c44431e8e99548d779891ecb399417bce8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 20:34:29 -0400 Subject: [PATCH] =?UTF-8?q?RMW=20pass=202=20=E2=80=94=20AbCip=20BOOL-withi?= =?UTF-8?q?n-DINT=20+=20AbLegacy=20bit-within-word.=20Closes=20task=20#181?= =?UTF-8?q?.=20AbCip=20=E2=80=94=20AbCipDriver.WriteAsync=20now=20detects?= =?UTF-8?q?=20BOOL=20writes=20with=20a=20bit=20index=20+=20routes=20them?= =?UTF-8?q?=20through=20WriteBitInDIntAsync:=20strip=20the=20.N=20suffix?= =?UTF-8?q?=20to=20form=20the=20parent=20DINT=20tag=20path=20(via=20AbCipT?= =?UTF-8?q?agPath=20with=20BitIndex=3Dnull=20+=20ToLibplctagName),=20get/c?= =?UTF-8?q?reate=20a=20cached=20parent=20IAbCipTagRuntime=20via=20EnsurePa?= =?UTF-8?q?rentRuntimeAsync=20(distinct=20from=20the=20bit-selector=20tag?= =?UTF-8?q?=20runtime=20so=20read=20+=20write=20target=20the=20DINT=20dire?= =?UTF-8?q?ctly),=20acquire=20a=20per-parent-name=20SemaphoreSlim,=20Read?= =?UTF-8?q?=20=E2=86=92=20Convert.ToInt32=20the=20current=20DINT=20?= =?UTF-8?q?=E2=86=92=20(current=20|=201< --- .../AbCipDriver.cs | 103 +++++++++++- .../LibplctagTagRuntime.cs | 11 +- .../AbLegacyDriver.cs | 94 ++++++++++- .../LibplctagLegacyTagRuntime.cs | 6 +- .../AbCipBoolInDIntRmwTests.cs | 152 ++++++++++++++++++ .../AbCipDriverWriteTests.cs | 9 +- .../AbLegacyBitRmwTests.cs | 104 ++++++++++++ .../AbLegacyReadWriteTests.cs | 9 +- 8 files changed, 473 insertions(+), 15 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs 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]