Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs
T
Joseph Doherty cb16ebc75e fix(abcip): bit-in-word writes take the parent width from its read
H17: the .n read-modify-write hard-coded a DINT parent. On an INT or SINT host
(both legal in Logix, and reads of Flags.3 there already worked) the DINT
decode of a 2-byte buffer threw OutOfBounds, the generic catch evicted the BIT
tag's (non-existent) runtime and reported BadCommunicationError + Degraded.

The parent width now comes from the parent read's size (1 SINT / 2 INT /
4 DINT / 8 LINT; unknown keeps the DINT assumption). Narrow hosts use unsigned
arithmetic so a set top bit encodes without overflow; a bit past the host's
width is BadOutOfRange and a non-integer width BadTypeMismatch, both refused
before anything is written, warned once, no health write. A transport failure
in the RMW now evicts the parent runtime it actually used. Native end-to-end:
an INT host is written as ONE INT (type 0xC3, 2 bytes).

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:21:43 -04:00

128 lines
6.0 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H17: Logix allows a <c>.n</c> bit selector on SINT / INT / DINT (and LINT) hosts, and reads of
/// <c>Flags.3</c> on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its
/// <c>DecodeValue(DInt)</c> on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault
/// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipBitInWordWidthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-bit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades.</summary>
/// <param name="size">The parent's wire width.</param>
/// <param name="before">The parent's value before the write.</param>
/// <param name="bit">The bit written true.</param>
/// <param name="after">The value the parent must be written back with.</param>
[Theory]
[InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte
[InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode
[InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved
[InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode
[InlineData(4, 0x00000001, 3, 0x00000009)]
public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after)
{
var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A bit past the host's width is refused before anything is written, with no health write.</summary>
[Fact]
public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written()
{
var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A host whose width is no integer's is a type mismatch, refused before writing, no health write.</summary>
[Fact]
public async Task A_host_of_no_integer_width_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1");
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime against a fake CIP target whose <c>Flags</c> is an INT: the write
/// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved.
/// </summary>
[Fact]
public async Task A_native_bit_write_on_an_INT_host_writes_an_INT()
{
await using var target = new FakeCipTarget();
// Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success.
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80],
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT
write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element
write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3
write[^1].ShouldBe((byte)0x80);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await drv.DisposeAsync();
}
}
}