e863e9f0e2
A first-touch native write to a T/C/R status-bit sub-element (T4:0.DN) created and initialised the tag's runtime before the runtime's write refusal could fire; the init is one READ, so against a device lacking the file (the ab_server fixture has no T/C/R) it surfaced as BadCommunicationError + Degraded health instead of BadNotSupported. The bit-RMW path was not involved: AbLegacyAddress rejects sub-element + /N, so a status bit's BitIndex is always null. NativeAbLegacyTagRuntime.WriteRefusal is now the single predicate (status bit by address whatever the authored type; any PLC-5 write), asked by the driver via IAbLegacyTagFactory.WriteRefusal (default null, so libplctag is unchanged) before EnsureTagRuntimeAsync and ahead of the bit-RMW branch. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
174 lines
8.8 KiB
C#
174 lines
8.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #593 follow-up: a native write the runtime would refuse (a T/C/R status-bit sub-element such as
|
|
/// <c>T4:0.DN</c>, or any PLC-5 write) is refused by the DRIVER before a runtime is created — so the
|
|
/// refusal holds on a FIRST-TOUCH write too, with zero PCCC requests on the wire.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Before the fix the write path created and initialised the tag's runtime first, and a native init is one
|
|
/// READ. Against a device whose T/C/R file is missing — the <c>ab_server</c> fixture has none — that read
|
|
/// failed, so the write surfaced as <c>BadCommunicationError</c>, evicted the handle and degraded health
|
|
/// (the comms-loss watchdog's input) instead of the documented <c>BadNotSupported</c>. These tests run the
|
|
/// PRODUCTION <see cref="NativeAbLegacyTagFactory"/> over a real socket to a <see cref="FakeEipServer"/>
|
|
/// that, like <c>ab_server</c>, only has an N7 file, and count the PCCC requests it receives.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class NativeAbLegacyRefusedWriteTests
|
|
{
|
|
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
|
|
|
|
/// <summary>A PCCC-only fake: N7 exists (reads answer one zero word, writes succeed); every other file answers
|
|
/// "file does not exist" (STS F0 / EXT 06 → NotFound), as a device without that file would.</summary>
|
|
private sealed class N7OnlyServer : IAsyncDisposable
|
|
{
|
|
private int _pccc;
|
|
|
|
public FakeEipServer Server { get; } = new() { ZeroReplyContext = true };
|
|
|
|
/// <summary>Execute PCCC requests received (reads AND writes).</summary>
|
|
public int PcccRequests => Volatile.Read(ref _pccc);
|
|
|
|
public N7OnlyServer()
|
|
{
|
|
Server.Handler = req =>
|
|
{
|
|
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
|
|
var cip = FakeEipServer.RequestCip(req);
|
|
Interlocked.Increment(ref _pccc);
|
|
// cip: 4B 02 20 67 24 01 | id(7) | cmd 0F, sts, tns(2), fnc, byteCount, file#, fileType, element, sub
|
|
var fileNumber = cip[19];
|
|
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
|
|
return fileNumber == 7
|
|
? FakeEipServer.RRReply(req, [.. head, 0x00, cip[15], cip[16], .. cip[17] == 0xA2 ? new byte[] { 0, 0 } : []])
|
|
: FakeEipServer.RRReply(req, [.. head, 0xF0, cip[15], cip[16], 0x06]); // EXT 06: file does not exist
|
|
};
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => Server.DisposeAsync();
|
|
}
|
|
|
|
// These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience
|
|
// wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
|
|
#pragma warning disable OTOPCUA0001
|
|
private static async Task<AbLegacyDriver> DriverAsync(
|
|
N7OnlyServer s, AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
|
|
{
|
|
var host = $"ab://127.0.0.1:{s.Server.Port}/1,0";
|
|
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(host, family)],
|
|
RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = host }).ToArray()),
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-refused-write", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
return driver;
|
|
}
|
|
|
|
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) =>
|
|
new(name, "", address, type, Writable: writable);
|
|
|
|
/// <summary>
|
|
/// A first-touch status-bit write is BadNotSupported with nothing on the wire and health unchanged, even
|
|
/// though the device has no such file. The positive control (an N7 read on the same driver afterwards) is
|
|
/// what proves the fake counts requests — and that the refusal left the connection usable.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("T4:0.DN", AbLegacyDataType.Bit)]
|
|
[InlineData("C5:0.CU", AbLegacyDataType.Bit)]
|
|
[InlineData("R6:0.EN", AbLegacyDataType.Bit)]
|
|
[InlineData("T4:0.TT", AbLegacyDataType.Int)] // the refusal follows the ADDRESS, not the authored type
|
|
[InlineData("C5:0.DN", AbLegacyDataType.TimerElement)]
|
|
public async Task A_first_touch_status_bit_write_is_BadNotSupported_with_nothing_on_the_wire(
|
|
string address, AbLegacyDataType type)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var s = new N7OnlyServer();
|
|
var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500,
|
|
Tag("Scratch", "N7:5", AbLegacyDataType.Int),
|
|
Tag("Bit", address, type, writable: true));
|
|
var health = driver.GetHealth();
|
|
|
|
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], ct);
|
|
|
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
s.PcccRequests.ShouldBe(0);
|
|
driver.GetHealth().ShouldBe(health);
|
|
|
|
// Positive control: the same driver does reach this server, and the refusal did not poison anything.
|
|
(await driver.ReadAsync(["Scratch"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
s.PcccRequests.ShouldBeGreaterThan(0); // init read + read
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
await driver.ShutdownAsync(ct);
|
|
s.Server.HandlerErrors.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>A PLC-5 write (including a bit-within-word RMW, which would otherwise READ the parent word
|
|
/// first) is refused with nothing on the wire.</summary>
|
|
[Theory]
|
|
[InlineData("N7:5", AbLegacyDataType.Int, (short)7)]
|
|
[InlineData("N7:5/3", AbLegacyDataType.Bit, true)]
|
|
public async Task A_first_touch_PLC5_write_is_BadNotSupported_with_nothing_on_the_wire(
|
|
string address, AbLegacyDataType type, object value)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var s = new N7OnlyServer();
|
|
var driver = await DriverAsync(s, AbLegacyPlcFamily.Plc5, Tag("W", address, type, writable: true));
|
|
var health = driver.GetHealth();
|
|
|
|
(await driver.WriteAsync([new WriteRequest("W", value)], ct)).Single().StatusCode
|
|
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
|
|
s.PcccRequests.ShouldBe(0);
|
|
driver.GetHealth().ShouldBe(health);
|
|
await driver.ShutdownAsync(ct);
|
|
}
|
|
|
|
/// <summary>Positive control for the refusal's scope: an ordinary SLC write still reaches the wire, so the
|
|
/// pre-check is not swallowing every write (the T/C/R word sub-element case is
|
|
/// <c>NativeAbLegacyTagRuntimeTests.A_word_sub_element_write_through_the_driver_sends_a_typed_write</c>).</summary>
|
|
[Fact]
|
|
public async Task An_SLC_word_write_still_reaches_the_wire()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var s = new N7OnlyServer();
|
|
var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500, Tag("W", "N7:5", AbLegacyDataType.Int, writable: true));
|
|
|
|
(await driver.WriteAsync([new WriteRequest("W", (short)7)], ct)).Single().StatusCode
|
|
.ShouldBe(AbLegacyStatusMapper.Good);
|
|
|
|
s.PcccRequests.ShouldBeGreaterThanOrEqualTo(1);
|
|
await driver.ShutdownAsync(ct);
|
|
}
|
|
#pragma warning restore OTOPCUA0001
|
|
|
|
[Theory]
|
|
[InlineData("slc500", "T4:0.DN", true)]
|
|
[InlineData("slc500", "C5:0.UA", true)]
|
|
[InlineData("micrologix", "R6:0.FD", true)]
|
|
[InlineData("slc500", "T4:0.PRE", false)]
|
|
[InlineData("slc500", "T4:0.ACC", false)]
|
|
[InlineData("slc500", "R6:0.POS", false)]
|
|
[InlineData("slc500", "N7:0/3", false)]
|
|
[InlineData("slc500", "B3:0/0", false)]
|
|
[InlineData("slc500", "N7:0", false)]
|
|
[InlineData("plc5", "N7:0", true)]
|
|
[InlineData("plc5", "T4:0.PRE", true)]
|
|
[InlineData("slc500", "T4:0.NOPE", false)] // unknown sub-element: refused by the runtime's own address check
|
|
[InlineData("slc500", "garbage", false)]
|
|
public void The_write_refusal_predicate_covers_status_bits_and_PLC5_only(string plc, string tag, bool refused)
|
|
{
|
|
var p = new AbLegacyTagCreateParams("h", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(1));
|
|
(NativeAbLegacyTagRuntime.WriteRefusal(p) is not null).ShouldBe(refused);
|
|
(new NativeAbLegacyTagFactory().WriteRefusal(p) is not null).ShouldBe(refused);
|
|
}
|
|
}
|