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
298 lines
13 KiB
C#
298 lines
13 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATCapabilityTests
|
|
{
|
|
// ---- ITagDiscovery ----
|
|
|
|
/// <summary>Verifies that DiscoverAsync emits pre-declared tags.</summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_emits_pre_declared_tags()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
|
RawTags = TwinCATRawTags.Entries(
|
|
[
|
|
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
|
|
]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "TwinCAT");
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
|
builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
|
|
// Each variable states its IEC type, so a browse-commit binds the tag with the type the PLC
|
|
// declares rather than TwinCATTagConfigModel's DInt default (Gitea #536). It cannot be
|
|
// recovered from Info.DriverDataType — Byte/Int16/Int32 all land there as Int32.
|
|
builder.Variables.Single(v => v.BrowseName == "Status").Info.NativeDataType.ShouldBe("Bool");
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.NativeDataType.ShouldBe("DInt");
|
|
}
|
|
|
|
// ---- ISubscribable ----
|
|
|
|
/// <summary>Verifies that Subscribe initial poll raises OnDataChange.</summary>
|
|
[Fact]
|
|
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } },
|
|
};
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseNativeNotifications = false, // poll-mode test
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
|
|
|
events.First().Snapshot.Value.ShouldBe(42);
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that ShutdownAsync cancels active subscriptions.</summary>
|
|
[Fact]
|
|
public async Task ShutdownAsync_cancels_active_subscriptions()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
|
|
};
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseNativeNotifications = false, // poll-mode test
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
|
|
var afterShutdown = events.Count;
|
|
await Task.Delay(200);
|
|
events.Count.ShouldBe(afterShutdown);
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
/// <summary>Verifies that GetHostStatuses returns entry per device.</summary>
|
|
[Fact]
|
|
public async Task GetHostStatuses_returns_entry_per_device()
|
|
{
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
|
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
|
|
],
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
drv.GetHostStatuses().Count.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>Verifies that Probe transitions to Running on successful probe.</summary>
|
|
[Fact]
|
|
public async Task Probe_transitions_to_Running_on_successful_probe()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
Customise = () => new FakeTwinCATClient { ProbeResult = true },
|
|
};
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
Probe = new TwinCATProbeOptions
|
|
{
|
|
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
|
Timeout = TimeSpan.FromMilliseconds(50),
|
|
},
|
|
}, "drv-1", factory);
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
|
|
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that Probe transitions to Stopped on probe failure.</summary>
|
|
[Fact]
|
|
public async Task Probe_transitions_to_Stopped_on_probe_failure()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
Customise = () => new FakeTwinCATClient { ProbeResult = false },
|
|
};
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
Probe = new TwinCATProbeOptions
|
|
{
|
|
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
|
Timeout = TimeSpan.FromMilliseconds(50),
|
|
},
|
|
}, "drv-1", factory);
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
|
|
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that Probe is disabled when Enabled is false.</summary>
|
|
[Fact]
|
|
public async Task Probe_disabled_when_Enabled_is_false()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await Task.Delay(200);
|
|
|
|
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- IPerCallHostResolver ----
|
|
|
|
/// <summary>Verifies that ResolveHost returns declared device for known tag.</summary>
|
|
[Fact]
|
|
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
|
{
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
|
|
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
|
|
],
|
|
RawTags = TwinCATRawTags.Entries(
|
|
[
|
|
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("B", "ads://5.23.91.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
|
|
]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
drv.ResolveHost("A").ShouldBe("ads://5.23.91.23.1.1:851");
|
|
drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
|
|
}
|
|
|
|
/// <summary>Verifies that ResolveHost falls back to first device for unknown reference.</summary>
|
|
[Fact]
|
|
public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
|
|
{
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
|
|
}
|
|
|
|
/// <summary>Verifies that ResolveHost falls back to unresolved sentinel when no devices.</summary>
|
|
[Fact]
|
|
public async Task ResolveHost_falls_back_to_unresolved_sentinel_when_no_devices()
|
|
{
|
|
// Driver.TwinCAT-006: empty-string sentinel — DriverInstanceId is a config-DB key, not
|
|
// a host address, so it would collide with no GetHostStatuses() row.
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
drv.ResolveHost("anything").ShouldBe(TwinCATDriver.UnresolvedHostSentinel);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
|
{
|
|
/// <summary>Gets the list of folders added to the address space.</summary>
|
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
|
/// <summary>Gets the list of variables added to the address space.</summary>
|
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
|
|
|
/// <summary>Adds a folder with the specified browse name and display name.</summary>
|
|
/// <param name="browseName">The browse name of the folder.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
/// <returns>This builder instance.</returns>
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
|
{ Folders.Add((browseName, displayName)); return this; }
|
|
|
|
/// <summary>Adds a variable with the specified browse name, display name, and attribute info.</summary>
|
|
/// <param name="browseName">The browse name of the variable.</param>
|
|
/// <param name="displayName">The display name of the variable.</param>
|
|
/// <param name="info">The driver attribute information for the variable.</param>
|
|
/// <returns>A variable handle for the added variable.</returns>
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
|
|
|
/// <summary>Adds a property to a variable.</summary>
|
|
/// <param name="_">The property name.</param>
|
|
/// <param name="__">The property data type.</param>
|
|
/// <param name="___">The property value.</param>
|
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
|
|
|
private sealed class Handle(string fullRef) : IVariableHandle
|
|
{
|
|
/// <summary>Gets the full reference name of the variable.</summary>
|
|
public string FullReference => fullRef;
|
|
/// <summary>Marks the variable as an alarm condition.</summary>
|
|
/// <param name="info">The alarm condition information.</param>
|
|
/// <returns>An alarm condition sink.</returns>
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
|
}
|
|
private sealed class NullSink : IAlarmConditionSink {
|
|
/// <summary>Called when an alarm transitions.</summary>
|
|
/// <param name="args">The alarm event arguments.</param>
|
|
public void OnTransition(AlarmEventArgs args) { } }
|
|
}
|
|
}
|