459cbb444e
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native runtime now keeps the original element path until the first reply settles the layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g. a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to Bits[n] and re-reads, and a write settles the layout by a read first, so it can never decode or write element n/32 of an element-addressed array. I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved). ErrorNotImplemented - and the native runtime's own pre-send refusals - raise AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT advanced, no backoff record, not reachable for the probe or Test Connect. M1: the bit RMW's parent read/write status words follow the health contract (refusal: per-tag Bad + Warning; comms: evict the parent + Degraded). M2: device Unsupported read refusals return Refused (advance LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the pre-send native type gate). M3: a good write clears a Degraded a write comms fault recorded (a read-side Degraded is still left for the read path). M4: a superseded init throws AbCipInitializationSupersededException (not an OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile. M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the unencodable-array test asserts health. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
162 lines
7.7 KiB
C#
162 lines
7.7 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead
|
|
/// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_failed_parent_read_follows_the_health_contract()
|
|
{
|
|
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
|
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection };
|
|
var before = drv.GetHealth();
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
|
factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue();
|
|
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
|
}
|
|
|
|
/// <summary>M1 positive control: a refused parent read keeps the runtime, no health write.</summary>
|
|
[Fact]
|
|
public async Task A_refused_parent_read_keeps_the_runtime_and_health()
|
|
{
|
|
var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3");
|
|
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed };
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
|
factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse();
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
}
|