diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index b219d86a..430fbedb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -360,12 +360,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { 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") + // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N + // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every + // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files + // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements + // don't reach this path because they're not Bit typed. NOTE: an Input-image (I) write is sent + // to the PLC like any other write — the device drives the input image from physical inputs and + // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. + if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit) { results[i] = new WriteResult( await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs index b1ba8804..8f0b07a0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs @@ -105,4 +105,76 @@ public sealed class AbLegacyBitRmwTests factory.Tags["N7:0"].WriteCount.ShouldBe(2); Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5 } + + /// B/I/O-file bit set reads the parent word, ORs the bit, writes it back (was BadNotSupported). + [Theory] + [InlineData("B3:0/3", "B3:0")] + [InlineData("I:0/3", "I:0")] + [InlineData("O:1/3", "O:1")] + public async Task Bit_set_on_B_I_O_file_RMWs_parent_word(string bitAddr, string parentName) + { + 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("F", "ab://10.0.0.5/1,0", bitAddr, AbLegacyDataType.Bit)], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync([new WriteRequest("F", true)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Tags.ShouldContainKey(parentName); + Convert.ToInt32(factory.Tags[parentName].Value).ShouldBe(0b1001); + } + + /// B-file bit clear preserves the other bits in the parent word. + [Fact] + public async Task Bit_clear_preserves_other_bits_in_B_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", "B3: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["B3:0"].Value).ShouldBe(unchecked((short)0xFFF7)); + } + + /// Concurrent bit writes to the same B-file word compose correctly (per-parent lock). + [Fact] + public async Task Concurrent_bit_writes_to_same_B_file_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", $"B3: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["B3:0"].Value).ShouldBe(0xFF); + } }