6b3da93151
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.
The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.
Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.
--- and the other half, which the fixture was hiding ---
Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:
* @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
@udt/ are), so it goes out as an ordinary symbolic name that no controller
defines, and the controller's "tag does not exist" IS the successful answer.
* ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
classifies oppositely. Fixed in vendored patch 0002.
* With the fixture then telling the truth, the real defect surfaced:
AbCipDriverProbe.IsReachableException matched the native C status spellings
("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
("ErrorNotFound"). Those substrings can never match, so the reachable
classification could not fire on the exception path — which is the path every
no-authored-tags probe takes. This was failing on real hardware too, not just
against the sim. It now recovers the Status and defers to IsReachableStatus,
so there is one classification rather than two that can drift.
Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.
Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".
Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
190 lines
8.4 KiB
C#
190 lines
8.4 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using libplctag;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="AbCipDriverProbe"/>. Covers the three offline-determinable
|
|
/// failure paths: invalid JSON, missing host/port, and an unreachable (TCP-level rejected)
|
|
/// target. The happy path (real CIP EIP session + <c>Ok=true</c>) and the
|
|
/// "controller reachable but probe tag not found" path require a live CIP controller or
|
|
/// simulator and are covered in integration tests against the AB-CIP Docker fixture
|
|
/// (<c>10.100.0.35:44818</c>).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipDriverProbeTests
|
|
{
|
|
private static readonly AbCipDriverProbe Probe = new();
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 1. Invalid JSON
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
|
|
[Fact]
|
|
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
|
|
{
|
|
var result = await Probe.ProbeAsync(
|
|
"not-valid-json{{{",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ToLowerInvariant().ShouldContain("invalid");
|
|
result.Latency.ShouldBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 2. Config with no device / no host
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A config with an empty Devices list returns Ok=false with a "no host/port" message.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
|
|
{
|
|
var result = await Probe.ProbeAsync(
|
|
"{\"devices\":[]}",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("no host/port");
|
|
result.Latency.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A config whose first device carries a malformed host address (not ab://) returns
|
|
/// Ok=false with a "no host/port" message because <see cref="AbCipHostAddress.TryParse"/>
|
|
/// returns null for the address.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
|
|
{
|
|
// "not-an-ab-url" is not an ab:// URL — TryParse returns null.
|
|
var result = await Probe.ProbeAsync(
|
|
"{\"devices\":[{\"hostAddress\":\"not-an-ab-url\"}]}",
|
|
TimeSpan.FromSeconds(3),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("no host/port");
|
|
result.Latency.ShouldBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 3. Unreachable target — TCP connect fails at Phase 1
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>;
|
|
/// the probe must return Ok=false with a "Connect failed" message.
|
|
/// The port is reserved then released so the OS refuses the connection immediately
|
|
/// (no black-hole delay needed).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
|
|
{
|
|
// Reserve an ephemeral port then release it so the port is definitely closed.
|
|
var reserved = new TcpListener(IPAddress.Loopback, 0);
|
|
reserved.Start();
|
|
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
|
|
reserved.Stop();
|
|
|
|
// Use a short timeout — we only need Phase 1 to fail fast.
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
|
TestContext.Current.CancellationToken);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
|
|
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}";
|
|
var result = await Probe.ProbeAsync(
|
|
configJson,
|
|
TimeSpan.FromSeconds(3),
|
|
cts.Token);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
// Phase 1 SocketException → "Connect failed: <SocketErrorCode>"
|
|
result.Message!.ShouldContain("Connect failed");
|
|
result.Latency.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the
|
|
/// probe to time out at Phase 1 when given a very short cancellation window.
|
|
/// The probe must return Ok=false with a "timed out" message.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutMessage()
|
|
{
|
|
// 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes.
|
|
// Use a 1-second cancellation so the test stays fast on any network.
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
|
TestContext.Current.CancellationToken);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(1));
|
|
|
|
var configJson = "{\"devices\":[{\"hostAddress\":\"ab://192.0.2.1/1,0\"}]}";
|
|
var result = await Probe.ProbeAsync(
|
|
configJson,
|
|
TimeSpan.FromSeconds(1),
|
|
cts.Token);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull();
|
|
// Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on
|
|
// some network stacks) — both are acceptable "not reachable" outcomes.
|
|
var lower = result.Message!.ToLowerInvariant();
|
|
(lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue(
|
|
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
|
|
result.Latency.ShouldBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. Reachable-vs-failed classification (offline — no controller needed)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The exception path classifies by the SAME rule as the status path. libplctag.NET builds
|
|
/// <see cref="LibPlcTagException"/> with <c>Status.ToString()</c>, so its message is
|
|
/// "ErrorNotFound" — this method used to look for the native C spelling "STATUS_NOT_FOUND",
|
|
/// which that message can never contain, so a controller that answered "tag does not exist"
|
|
/// was reported as a failed handshake. That is the answer every Test Connect gets on a device
|
|
/// with no authored tags, i.e. every device at the moment an operator first presses the button.
|
|
/// </summary>
|
|
/// <param name="status">The status libplctag threw on.</param>
|
|
/// <param name="expectedReachable">Whether the controller should be reported reachable.</param>
|
|
[Theory]
|
|
[InlineData(Status.ErrorNotFound, true)]
|
|
[InlineData(Status.ErrorNoMatch, true)]
|
|
[InlineData(Status.ErrorBadDevice, true)]
|
|
[InlineData(Status.ErrorTimeout, false)]
|
|
[InlineData(Status.ErrorBadConnection, false)]
|
|
[InlineData(Status.ErrorBadGateway, false)]
|
|
[InlineData(Status.ErrorBadParam, false)]
|
|
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(Status status, bool expectedReachable)
|
|
{
|
|
// Exactly how NativeTagWrapper.ThrowIfStatusNotOk constructs it.
|
|
var ex = new LibPlcTagException(status.ToString());
|
|
|
|
AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable);
|
|
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A message that names no status at all stays UNreachable rather than defaulting green — the
|
|
/// conservative half, and the one an over-eager "treat anything we cannot parse as reachable"
|
|
/// fix would break.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
|
|
=> AbCipDriverProbe.IsReachableException(
|
|
new LibPlcTagException("something the wrapper has never said")).ShouldBeFalse();
|
|
}
|