Merge branch 'master' into b626-opcuaclient

This commit is contained in:
Joseph Doherty
2026-10-08 14:47:49 -04:00
72 changed files with 3775 additions and 343 deletions
@@ -145,4 +145,17 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
}
/// <summary>The #288/#289 types parse to the CLR shape the codecs accept.</summary>
[Fact]
public void ParseValue_handles_the_string_and_date_time_codec_types()
{
WriteCommand.ParseValue("héllo", S7DataType.WString).ShouldBe("héllo");
WriteCommand.ParseValue("A", S7DataType.Char).ShouldBe("A");
WriteCommand.ParseValue("2008-12-16T20:30:20", S7DataType.Dtl).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("2009-12-01", S7DataType.Date).ShouldBeOfType<DateTime>();
WriteCommand.ParseValue("86399999", S7DataType.Tod).ShouldBe(86399999L);
WriteCommand.ParseValue("-1000", S7DataType.Time).ShouldBe(-1000L);
WriteCommand.ParseValue("127000", S7DataType.S5Time).ShouldBe(127000L);
}
}
@@ -229,6 +229,33 @@ public sealed class AbCipAlarmProjectionTests
}
/// <summary>Waits for <paramref name="more"/> further reads of the fake runtime (a processed-count wait).</summary>
/// <summary>
/// #630 — a re-Initialize on a live driver must dispose the alarm projection's poll loop rather
/// than leave it running (it would keep raising duplicate alarm events through this driver).
/// </summary>
[Fact]
public async Task Reinitialize_Stops_The_Previous_Projection_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
var tag = factory.Tags["HighTemp"];
// Positive control: the loop is demonstrably polling before the re-init.
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
await drv.InitializeAsync("{}", CancellationToken.None);
int Reads() => factory.Tags.Values.Sum(t => t.ReadCount); // the loop may re-create its runtime on the new device state
var frozen = Reads();
await Task.Delay(300); // ~15 poll intervals; a live loop would advance the count
Reads().ShouldBe(frozen);
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more)
{
var tag = factory.Tags[tagName];
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + handles. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsHandles()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(20),
Timeout = TimeSpan.FromMilliseconds(200),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and has initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["@raw_cpu_type"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,320 @@
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.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247).</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyFamilyAddressingTests
{
// ---- #244 PLC-5 octal I/O ----
/// <summary>PLC-5 I/O word and bit indices parse as octal and are stored decimal.</summary>
[Theory]
[InlineData("I:017/07", "I", 15, 7, "I:15/7")]
[InlineData("I:017/17", "I", 15, 15, "I:15/15")]
[InlineData("O:010/10", "O", 8, 8, "O:8/8")]
[InlineData("I:000", "I", 0, null, "I:0")]
[InlineData("I:12", "I", 10, null, "I:10")]
public void Plc5_io_indices_are_octal(string text, string letter, int word, int? bit, string libplctag)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.WordNumber.ShouldBe(word);
a.BitIndex.ShouldBe(bit);
a.ToLibplctagName().ShouldBe(libplctag);
}
/// <summary>The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers.</summary>
[Fact]
public void Plc5_octal_address_round_trips_through_the_family_neutral_parser()
{
var a = AbLegacyAddress.TryParse("I:017/07", AbLegacyPlcFamily.Plc5)!;
AbLegacyAddress.TryParse(a.ToLibplctagName()).ShouldBe(a);
}
/// <summary>A digit 8 or 9 is not octal: a clear parse failure, not a silent misread.</summary>
[Theory]
[InlineData("I:018")]
[InlineData("I:019/07")]
[InlineData("I:017/08")]
[InlineData("O:9/1")]
[InlineData("O:1/9")]
public void Plc5_io_rejects_8_and_9(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldBeNull();
/// <summary>Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.MicroLogix)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Non_plc5_io_stays_decimal(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("I:12/9", family).ShouldNotBeNull().WordNumber.ShouldBe(12);
AbLegacyAddress.TryParse("I:12/9", family)!.BitIndex.ShouldBe(9);
AbLegacyAddress.TryParse("I:12/9").ShouldNotBeNull().WordNumber.ShouldBe(12);
}
/// <summary>PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal.</summary>
[Fact]
public void Plc5_non_io_files_stay_decimal()
{
var a = AbLegacyAddress.TryParse("N7:12/9", AbLegacyPlcFamily.Plc5).ShouldNotBeNull();
a.WordNumber.ShouldBe(12);
a.BitIndex.ShouldBe(9);
}
// ---- #245 MicroLogix function files ----
/// <summary>MicroLogix parses the function-file letters, with their sub-elements.</summary>
[Theory]
[InlineData("RTC:0.HR", "RTC", 0, "HR")]
[InlineData("HSC:0.ACC", "HSC", 0, "ACC")]
[InlineData("pto:0.EN", "PTO", 0, "EN")]
[InlineData("STI:0.SPM", "STI", 0, "SPM")]
[InlineData("BHI:0.FRN", "BHI", 0, "FRN")]
public void MicroLogix_parses_function_files(string text, string letter, int element, string sub)
{
var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
a.FileLetter.ShouldBe(letter);
a.FileNumber.ShouldBeNull();
a.WordNumber.ShouldBe(element);
a.SubElement.ShouldBe(sub);
}
/// <summary>Function files are MicroLogix-only; every other family and the neutral grammar reject them.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500)]
[InlineData(AbLegacyPlcFamily.Plc5)]
[InlineData(AbLegacyPlcFamily.LogixPccc)]
public void Other_families_reject_function_files(AbLegacyPlcFamily family)
{
AbLegacyAddress.TryParse("RTC:0.HR", family).ShouldBeNull();
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
}
/// <summary>A function file is a fixed single instance and not bit-addressable.</summary>
[Theory]
[InlineData("RTC1:0.HR")]
[InlineData("RTC:0/3")]
[InlineData("HSC:0.ACC/2")]
public void MicroLogix_function_file_shape_is_validated(string text) =>
AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
/// <summary>The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32.</summary>
[Theory]
[InlineData("RTC", "HR", DriverDataType.Int32)]
[InlineData("RTC", "BL", DriverDataType.Boolean)]
[InlineData("HSC", "ACC", DriverDataType.Int32)]
[InlineData("HSC", "DN", DriverDataType.Boolean)]
[InlineData("PTO", "OUT", DriverDataType.Boolean)]
[InlineData("STI", "SPM", DriverDataType.Int32)]
[InlineData("RTC", "NOPE", DriverDataType.Int32)]
public void Function_file_catalogue(string file, string sub, DriverDataType expected) =>
AbLegacyFunctionFile.SubElementType(file, sub).ShouldBe(expected);
// ---- #247 indirect addressing ----
/// <summary>Both indirect forms parse, carry their inner address, and round-trip their text.</summary>
[Theory]
[InlineData("N7:[N7:0]", false, "N7:[N7:0]")]
[InlineData("N[N7:0]:5", true, "N[N7:0]:5")]
[InlineData("N7:[T4:1.ACC]", false, "N7:[T4:1.ACC]")]
[InlineData("B3:[N7:0]/4", false, "B3:[N7:0]/4")]
public void Indirect_forms_parse(string text, bool fileIndirect, string roundTrip)
{
var a = AbLegacyAddress.TryParse(text).ShouldNotBeNull();
a.IsIndirect.ShouldBeTrue();
(a.IndirectFileSource is not null).ShouldBe(fileIndirect);
(a.IndirectWordSource is not null).ShouldBe(!fileIndirect);
a.ToLibplctagName().ShouldBe(roundTrip);
}
/// <summary>Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected.</summary>
[Theory]
[InlineData("N7:[N[N7:0]:1]")]
[InlineData("N7:[]")]
[InlineData("N7:[Q9:0]")]
[InlineData("N7:[N7:0")]
[InlineData("N7:N7:0]")]
[InlineData("N7:[N7:[N7:0]]")]
public void Malformed_indirect_is_rejected(string text) =>
AbLegacyAddress.TryParse(text).ShouldBeNull();
/// <summary>A direct address is not indirect.</summary>
[Fact]
public void Direct_address_is_not_indirect() =>
AbLegacyAddress.TryParse("N7:3/2")!.IsIndirect.ShouldBeFalse();
// ---- driver level ----
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
RawTags = AbLegacyRawTags.Entries(tags),
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index.</summary>
[Fact]
public async Task Plc5_octal_io_tag_is_handed_to_the_transport_decimal()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("In", "ab://10.0.0.5/1,0", "I:017/17", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = true };
var snap = (await drv.ReadAsync(["In"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ContainsKey("I:15/15").ShouldBeTrue();
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.MicroLogix,
new AbLegacyTagDefinition("Hour", "ab://10.0.0.5/1,0", "RTC:0.HR", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 14 };
var snap = (await drv.ReadAsync(["Hour"], CancellationToken.None)).Single();
snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snap.Value.ShouldBe(14);
factory.Tags.ContainsKey("RTC:0.HR").ShouldBeTrue();
}
// ---- native transport ----
private sealed class NativeHarness : IAsyncDisposable
{
private readonly FakeCipChannel _channel = new();
private readonly RequestScheduler _scheduler;
public NativeAbLegacyTagRuntime Runtime { get; }
public NativeHarness(string plc, string tag)
{
_scheduler = new RequestScheduler(_channel, allowPacking: false);
Runtime = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), 1),
_scheduler, 0x1234, 0x01020304);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them.</summary>
[Theory]
[InlineData("RTC:0.HR", "function file")]
[InlineData("N7:[N7:0]", "indirect")]
public async Task Native_transport_refuses_function_file_and_indirect_as_Unsupported(string tag, string why)
{
await using var h = new NativeHarness("micrologix", tag);
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.Unsupported);
ex.Message.ShouldContain(why, Case.Insensitive);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbLegacyDriver>
{
public readonly System.Collections.Concurrent.ConcurrentQueue<string> Warnings = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id,
TState state, Exception? ex, Func<TState, Exception?, string> f)
{
if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex));
}
}
/// <summary>
/// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on
/// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled.
/// </summary>
[Fact]
public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws()
{
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Bad"], CancellationToken.None);
var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:"));
logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1);
}
/// <summary>The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal).</summary>
[Fact]
public async Task Probe_address_is_normalised_like_tag_paths()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
Probe = new AbLegacyProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200),
ProbeAddress = "I:017",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags.ContainsKey("I:15").ShouldBeTrue();
factory.Tags.ContainsKey("I:017").ShouldBeFalse();
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + runtime. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsRuntime()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["S:0"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -69,8 +69,9 @@ public sealed class FocasCapabilityMatrixTests
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
[InlineData(FocasCncSeries.Sixteen_i, "M", false)]
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "focas://10.0.0.5:8193";
var factory = new FakeFocasClientFactory();
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitUntilAsync(Func<bool> cond)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!cond())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
await Task.Delay(10);
}
}
}
@@ -30,6 +30,7 @@ public sealed class MTConnectObservationIndexTests
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadNotSupported = 0x803D0000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadDecodingError = 0x80070000u;
/// <summary>Every dataItemId both /current fixtures report.</summary>
private static readonly string[] AllFixtureDataItemIds =
@@ -48,7 +49,7 @@ public sealed class MTConnectObservationIndexTests
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
private static MTConnectStreamsResult Synthetic(
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false, int? sampleCount = null) =>
new(
InstanceId: 1L,
NextSequence: 2L,
@@ -57,7 +58,8 @@ public sealed class MTConnectObservationIndexTests
dataItemId,
value,
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
isStructured)]);
isStructured,
sampleCount)]);
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
@@ -412,13 +414,9 @@ public sealed class MTConnectObservationIndexTests
// -------------------------------------------------------------------------------- TIME_SERIES
/// <summary>
/// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
/// reports BadNotSupported rather than pretending: the vector is real data this build
/// cannot represent, which is neither a type mismatch nor a comms failure.
/// </summary>
/// <summary>#627: a Float64 array tag serves the whole vector as <c>double[]</c>, Good.</summary>
[Fact]
public void Time_series_authored_as_an_array_reports_BadNotSupported()
public void Time_series_authored_as_a_float64_array_parses_to_double_array()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -426,25 +424,93 @@ public sealed class MTConnectObservationIndexTests
var snap = idx.Get("dev1_vibration_ts");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>()
.ShouldBe([1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0]);
}
/// <summary>The variable-length shape (no declared ArrayDim) is served the same way.</summary>
[Fact]
public void Time_series_without_a_declared_length_is_served_variable_length()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2.5\n 3"));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBeOfType<double[]>().ShouldBe([1.0, 2.5, 3.0]);
}
[Fact]
public void Time_series_count_mismatching_the_observations_sampleCount_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", "1 2 3", sampleCount: 4));
var snap = idx.Get("ts");
snap.StatusCode.ShouldBe(BadDecodingError);
snap.Value.ShouldBeNull();
}
/// <summary>
/// The falsifiable half of the deferral: the vector must never be silently reduced to its
/// first element. A Good 1.1 here would be a wrong value an operator would trust.
/// </summary>
[Fact]
public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
public void Time_series_count_mismatching_a_fixed_ArrayDim_is_Bad()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true, ArrayDim: 5)]);
idx.Apply(Synthetic("ts", "1 2 3"));
idx.Get("ts").StatusCode.ShouldBe(BadDecodingError);
}
[Theory]
[InlineData("1 2 abc")]
[InlineData("1 NaN 3")]
[InlineData("1 UNAVAILABLE 3")]
public void Time_series_with_an_unusable_token_is_Bad_never_a_partial_vector(string text)
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", text));
var snap = idx.Get("ts");
snap.StatusCode.ShouldNotBe(Good);
snap.Value.ShouldBeNull();
}
[Fact]
public void Empty_time_series_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("ts", DriverDataType.Float64, IsArray: true)]);
idx.Apply(Synthetic("ts", " "));
idx.Get("ts").StatusCode.ShouldBe(BadNoCommunication);
}
[Fact]
public void A_non_float64_array_tag_stays_BadNotSupported()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Int32, IsArray: true)]);
idx.Apply(ParseFixture(CurrentFixture));
var snap = idx.Get("dev1_vibration_ts");
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNotSupported);
}
snap.Value.ShouldNotBe(1.1);
snap.StatusCode.ShouldNotBe(Good);
/// <summary>The parser carries the observation's own sampleCount / sampleRate attributes.</summary>
[Fact]
public void Parser_carries_time_series_sampleCount_and_sampleRate()
{
var obs = ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_vibration_ts");
obs.SampleCount.ShouldBe(10);
obs.SampleRate.ShouldBe(100.0);
ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_pos").SampleCount.ShouldBeNull();
}
/// <summary>
@@ -452,7 +518,7 @@ public sealed class MTConnectObservationIndexTests
/// representable state and must report it rather than the deferral code.
/// </summary>
[Fact]
public void Unavailable_wins_over_the_unsupported_array_shape()
public void Unavailable_array_tag_reports_no_communication()
{
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
@@ -219,15 +219,16 @@ public sealed class MTConnectReadTests
res[0].SourceTimestampUtc.ShouldNotBeNull();
}
/// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
/// <summary>A probe-declared TIME_SERIES item is read end to end as a <c>double[]</c> (#627).</summary>
[Fact]
public async Task Read_of_a_time_series_ref_reports_BadNotSupported()
public async Task Read_of_a_time_series_ref_returns_the_double_vector()
{
var (driver, _) = await InitializedDriverAsync();
var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken);
res[0].StatusCode.ShouldBe(BadNotSupported);
res[0].StatusCode.ShouldBe(0u);
res[0].Value.ShouldBeOfType<double[]>().Length.ShouldBe(10);
}
/// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
@@ -0,0 +1,30 @@
using Moq;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> first) used to
/// overwrite <c>Session</c> and orphan the previous session + its keep-alive handler.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReinitializeReleasesSessionTests
{
/// <summary>A second Initialize disposes the live session before attempting to build a new one.</summary>
[Fact]
public async Task Initialize_OnLiveDriver_DisposesThePreviousSession()
{
var options = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1" };
using var drv = new OpcUaClientDriver(options, "opcua-reinit");
var old = new Mock<ISession>();
drv.SetSessionForTest(old.Object);
// The new connect is refused (nothing listens on port 1) — what matters is what happened to the old one.
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
old.Verify(s => s.Dispose(), Times.AtLeastOnce);
drv.Session.ShouldBeNull();
}
}
@@ -482,6 +482,37 @@ public sealed class S7DriverReconnectTests
(factory.Created.Count - atFirstAttempt).ShouldBeLessThanOrEqualTo(1);
}
/// <summary>
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and
/// dispose the first connection rather than orphan them.
/// </summary>
[Fact]
public async Task Second_Initialize_stops_first_probe_loop_and_disposes_first_connection()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(ProbeEnabledOptions(), "s7-reinit", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var first = factory.Created[0];
// Positive control: the first probe loop is demonstrably running on the first connection.
await WaitUntilAsync(() => Volatile.Read(ref first.ReadStatusCount) >= 2, TimeSpan.FromSeconds(5));
Volatile.Read(ref first.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(2);
first.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
first.Disposed.ShouldBeTrue();
var frozen = Volatile.Read(ref first.ReadStatusCount);
var second = factory.Created[^1];
second.ShouldNotBeSameAs(first);
// Positive control for the absence check: the NEW loop keeps ticking over the same window.
var secondBefore = Volatile.Read(ref second.ReadStatusCount);
await WaitUntilAsync(() => Volatile.Read(ref second.ReadStatusCount) >= secondBefore + 3, TimeSpan.FromSeconds(5));
Volatile.Read(ref second.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(secondBefore + 3);
Volatile.Read(ref first.ReadStatusCount).ShouldBe(frozen);
}
private static S7DriverOptions ProbeEnabledOptions() => new()
{
Host = "192.0.2.1",
@@ -626,8 +657,12 @@ public sealed class S7DriverReconnectTests
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Number of probe <see cref="ReadStatusAsync"/> calls made on this connection.</summary>
public int ReadStatusCount;
public async Task ReadStatusAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReadStatusCount);
if (ReadStatusHangsUntilCancelled)
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
if (ReadStatusThrows is { } ex)
@@ -0,0 +1,233 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294).
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TsapAndPduTests
{
// ---- TSAP text ----
[Theory]
[InlineData("01.00", 0x0100)]
[InlineData("03.01", 0x0301)]
[InlineData("3.1", 0x0301)]
[InlineData("0301", 0x0301)]
[InlineData("0x0301", 0x0301)]
[InlineData("03:21", 0x0321)]
[InlineData(" 4d.57 ", 0x4D57)]
public void Tsap_text_parses(string text, int expected)
{
S7Tsap.TryParse(text, out var v).ShouldBeTrue();
v.ShouldBe((ushort)expected);
}
[Theory]
[InlineData("")]
[InlineData("zz.00")]
[InlineData("100.00")]
[InlineData("01.00.00")]
[InlineData("12345")]
[InlineData("-1")]
public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse();
[Fact]
public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01");
// ---- resolution ----
private static (byte, byte, byte, byte) Bytes(S7DriverOptions o)
{
var p = S7Tsap.Resolve(o).ShouldNotBeNull();
return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte);
}
[Fact]
public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() =>
S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull();
[Fact]
public void Local_override_replaces_only_the_local_tsap()
{
// S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived).
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" };
Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2));
}
[Fact]
public void Remote_override_replaces_the_whole_remote_tsap()
{
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" };
Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1));
}
[Theory]
[InlineData(S7ConnectionType.PG, 1)]
[InlineData(S7ConnectionType.OP, 2)]
[InlineData(S7ConnectionType.S7Basic, 3)]
public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first)
{
// rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23.
var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct };
var (_, _, rFirst, rSecond) = Bytes(o);
rFirst.ShouldBe((byte)first);
rSecond.ShouldBe((byte)0x23);
}
// ---- factory JSON ----
[Fact]
public void Json_accepts_string_number_and_enum_name_and_normalises()
{
var o = S7DriverFactoryExtensions.ParseOptions("d",
"""{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}""");
o.LocalTsap.ShouldBe("01.00");
o.RemoteTsap.ShouldBe("03.01");
o.ConnectionType.ShouldBeNull();
var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}""");
ct.ConnectionType.ShouldBe(S7ConnectionType.PG);
ct.RemoteTsap.ShouldBeNull();
}
[Fact]
public void Json_without_the_keys_leaves_the_defaults()
{
var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}""");
o.LocalTsap.ShouldBeNull();
o.RemoteTsap.ShouldBeNull();
o.ConnectionType.ShouldBeNull();
}
[Theory]
[InlineData("""{"host":"h","localTsap":"nope"}""")]
[InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")]
[InlineData("""{"host":"h","remoteTsap":99999999}""")]
[InlineData("""{"host":"h","connectionType":"Bogus"}""")]
[InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")]
public void Json_rejects_bad_tsap_settings(string json) =>
Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d", json));
[Fact]
public async Task Driver_hands_the_full_options_to_the_factory()
{
var factory = new CapturingFactory();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RemoteTsap = "03.01",
LocalTsap = "01.00",
};
using var drv = new S7Driver(opts, "s7-tsap", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01");
factory.Seen!.LocalTsap.ShouldBe("01.00");
}
// ---- PDU ----
[Fact]
public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown()
{
var factory = new CapturingFactory { Pdu = 480 };
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu", factory);
drv.NegotiatedPduSize.ShouldBe(0);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(480);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
[Fact]
public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw()
{
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
private sealed class CapturingFactory : IS7PlcFactory
{
public S7DriverOptions? Seen { get; private set; }
public int Pdu { get; init; }
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
new Plc(Pdu);
public IS7Plc Create(S7DriverOptions options)
{
Seen = options;
return new Plc(Pdu);
}
private sealed class Plc(int pdu) : IS7Plc
{
public bool IsConnected { get; private set; }
public int MaxPduSize => IsConnected ? pdu : 0;
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> i, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException();
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
[Fact]
public void Factory_conflict_message_matches_the_shared_detail()
{
var ex = Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d",
"""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}"""));
ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail);
}
// ---- Test Connect data-access check (PUT/GET) ----
[Fact]
public async Task Probe_data_access_succeeds_when_the_read_works() =>
(await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken))
.ShouldBeNull();
[Fact]
public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled()
{
var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new Exception("Accessing object not allowed"));
var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken);
r.ShouldNotBeNull().Ok.ShouldBeFalse();
r.Message!.ShouldContain("PUT/GET");
}
[Fact]
public async Task Probe_tolerates_an_address_not_available_read_error()
{
var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist"));
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
}
[Fact]
public async Task Probe_reports_any_other_read_fault_as_a_failure()
{
var r = await S7DriverProbe.CheckDataAccessAsync(
_ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"),
TestContext.Current.CancellationToken);
r.ShouldNotBeNull().Ok.ShouldBeFalse();
r.Message!.ShouldContain("socket dropped");
}
}
@@ -0,0 +1,438 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) —
/// the pure <see cref="S7ValueCodecs"/> plus the driver's dispatch
/// (<see cref="S7Driver.ScalarByteWidth"/> / <c>DecodeScalarBlock</c> / <c>EncodeScalarBlock</c>) and one
/// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ValueCodecTests
{
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254, string address = "DB1.DBB0") =>
new("T", address, dt, StringLength: stringLength);
private static S7ParsedAddress Addr() =>
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
private static object Decode(S7DataType dt, byte[] block, int stringLength = 254) =>
S7Driver.DecodeScalarBlock(Tag(dt, stringLength), Addr(), block);
private static byte[] Encode(S7DataType dt, object? value, int stringLength = 254) =>
S7Driver.EncodeScalarBlock(Tag(dt, stringLength), value);
// ---- widths ----
[Theory]
[InlineData(S7DataType.Char, 1)]
[InlineData(S7DataType.WChar, 2)]
[InlineData(S7DataType.Date, 2)]
[InlineData(S7DataType.S5Time, 2)]
[InlineData(S7DataType.Tod, 4)]
[InlineData(S7DataType.Time, 4)]
[InlineData(S7DataType.Dtl, 12)]
public void Fixed_widths(S7DataType dt, int width) =>
S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(width);
[Theory]
[InlineData(0, 4)]
[InlineData(1, 6)]
[InlineData(5, 14)]
[InlineData(16382, 32768)]
public void WString_width_is_header_plus_two_bytes_per_char(int maxLen, int width) =>
S7Driver.ScalarByteWidth(Tag(S7DataType.WString, maxLen), Addr()).ShouldBe(width);
// ---- WSTRING ----
[Fact]
public void WString_encode_known_vector()
{
// maxLen=5, curLen=2, "Hi" UTF-16BE, 3 zero-padded chars.
Encode(S7DataType.WString, "Hi", 5).ShouldBe(
[0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0, 0, 0, 0, 0, 0]);
}
[Fact]
public void WString_decode_ignores_padding_past_current_length()
{
byte[] block = [0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0x00, 0x58, 0x00, 0x58, 0, 0];
Decode(S7DataType.WString, block, 5).ShouldBe("Hi");
}
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("Grüße Ω")]
[InlineData("12345678")] // exactly at the declared max
public void WString_round_trips_including_boundary_lengths(string s)
{
var bytes = Encode(S7DataType.WString, s, 8);
bytes.Length.ShouldBe(4 + 16);
Decode(S7DataType.WString, bytes, 8).ShouldBe(s);
}
[Fact]
public void WString_empty_with_zero_max_is_a_bare_header()
{
var bytes = Encode(S7DataType.WString, "", 0);
bytes.ShouldBe([0, 0, 0, 0]);
Decode(S7DataType.WString, bytes, 0).ShouldBe("");
}
[Fact]
public void WString_write_over_declared_max_is_refused_not_truncated() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.WString, "123456789", 8));
[Fact]
public void WString_decode_rejects_curLen_above_maxLen() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x02, 0x00, 0x03, 0, 0x41, 0, 0x42, 0, 0x43], 2));
[Fact]
public void WString_decode_rejects_length_running_past_block() =>
Should.Throw<S7ValueRangeException>(() =>
Decode(S7DataType.WString, [0x00, 0x05, 0x00, 0x04, 0, 0x41], 5));
// ---- CHAR / WCHAR ----
[Fact]
public void Char_known_vectors()
{
Encode(S7DataType.Char, "A").ShouldBe([0x41]);
Encode(S7DataType.Char, "é").ShouldBe([0xE9]);
Decode(S7DataType.Char, [0x41]).ShouldBe("A");
Decode(S7DataType.Char, [0xE9]).ShouldBe("é");
}
[Fact]
public void Char_refuses_wide_character_and_wrong_length()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Char, "Ā")); // U+0100
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, "AB"));
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, ""));
}
[Fact]
public void WChar_known_vectors()
{
Encode(S7DataType.WChar, "Ω").ShouldBe([0x03, 0xA9]);
Encode(S7DataType.WChar, "A").ShouldBe([0x00, 0x41]);
Decode(S7DataType.WChar, [0x03, 0xA9]).ShouldBe("Ω");
}
// ---- DTL ----
// Siemens documentation example: DTL#2008-12-16-20:30:20.250 (a Tuesday → S7 weekday 3).
private static readonly byte[] DtlVector = [0x07, 0xD8, 0x0C, 0x10, 0x03, 0x14, 0x1E, 0x14, 0x0E, 0xE6, 0xB2, 0x80];
[Fact]
public void Dtl_known_vector_both_ways()
{
var expected = new DateTime(2008, 12, 16, 20, 30, 20, 250);
Decode(S7DataType.Dtl, DtlVector).ShouldBe(expected);
Encode(S7DataType.Dtl, expected).ShouldBe(DtlVector);
}
[Fact]
public void Dtl_keeps_100ns_resolution()
{
var dt = new DateTime(2030, 1, 2, 3, 4, 5).AddTicks(1234567);
Decode(S7DataType.Dtl, Encode(S7DataType.Dtl, dt)).ShouldBe(dt);
}
[Fact]
public void Dtl_uninitialised_zero_buffer_is_rejected_not_year_zero() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, new byte[12]));
[Fact]
public void Dtl_invalid_calendar_fields_are_rejected()
{
var bad = (byte[])DtlVector.Clone();
bad[2] = 13; // month 13
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
bad = (byte[])DtlVector.Clone();
bad[8] = 0xFF; // nanoseconds > 999,999,999
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
}
[Theory]
[InlineData(1969)]
[InlineData(2263)]
public void Dtl_write_outside_plc_year_range_is_refused(int year) =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Dtl, new DateTime(year, 1, 1)));
// ---- DT (existing S7DataType.DateTime — regression vector) ----
[Fact]
public void Dt_still_routes_through_the_8_byte_bcd_codec()
{
var dt = new DateTime(2008, 12, 16, 20, 30, 20, 250);
var bytes = Encode(S7DataType.DateTime, dt);
bytes.Length.ShouldBe(8);
bytes.Take(7).ToArray().ShouldBe([0x08, 0x12, 0x16, 0x20, 0x30, 0x20, 0x25]);
Decode(S7DataType.DateTime, bytes).ShouldBe(dt);
}
// ---- DATE ----
[Theory]
[InlineData(1990, 1, 1, 0x0000)]
[InlineData(2000, 2, 29, 0x0E7F)]
[InlineData(2009, 12, 1, 0x1C6A)]
[InlineData(2168, 12, 31, 0xFF62)]
public void Date_known_vectors(int y, int m, int d, int days)
{
byte[] bytes = [(byte)(days >> 8), (byte)days];
Decode(S7DataType.Date, bytes).ShouldBe(new DateTime(y, m, d));
Encode(S7DataType.Date, new DateTime(y, m, d, 17, 5, 0)).ShouldBe(bytes); // time-of-day dropped
}
[Fact]
public void Date_write_outside_uint16_range_is_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31)));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(2169, 6, 7)));
Encode(S7DataType.Date, new DateTime(2169, 6, 6)).ShouldBe([0xFF, 0xFF]);
}
// ---- TOD ----
[Theory]
[InlineData(0, new byte[] { 0, 0, 0, 0 })]
[InlineData(1000, new byte[] { 0, 0, 0x03, 0xE8 })]
[InlineData(86_399_999, new byte[] { 0x05, 0x26, 0x5B, 0xFF })] // TOD#23:59:59.999
public void Tod_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Tod, bytes).ShouldBe(ms);
Encode(S7DataType.Tod, ms).ShouldBe(bytes);
}
[Fact]
public void Tod_boundaries_are_refused_on_both_sides()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, 86_400_000));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, -1));
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Tod, [0x05, 0x26, 0x5C, 0x00]));
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
}
[Fact]
public void Fractional_millisecond_double_rounds_like_the_other_int32_writes()
{
// Pinned: matches BoxValueForWrite's Convert.ToInt32 behaviour (banker's), see docs.
Encode(S7DataType.Tod, 100.7).ShouldBe(Encode(S7DataType.Tod, 101));
Encode(S7DataType.Time, 100.7).ShouldBe(Encode(S7DataType.Time, 101));
Encode(S7DataType.S5Time, 99.7).ShouldBe(Encode(S7DataType.S5Time, 100));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 100.7)); // rounds to 101: off the grid
}
// ---- TIME ----
[Theory]
[InlineData(86_400_000, new byte[] { 0x05, 0x26, 0x5C, 0x00 })] // T#1d
[InlineData(-1000, new byte[] { 0xFF, 0xFF, 0xFC, 0x18 })] // T#-1s
[InlineData(int.MaxValue, new byte[] { 0x7F, 0xFF, 0xFF, 0xFF })] // T#24d20h31m23s647ms
[InlineData(int.MinValue, new byte[] { 0x80, 0, 0, 0 })]
public void Time_known_vectors(int ms, byte[] bytes)
{
Decode(S7DataType.Time, bytes).ShouldBe(ms);
Encode(S7DataType.Time, ms).ShouldBe(bytes);
}
[Fact]
public void Time_outside_int32_is_refused() =>
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Time, (long)int.MaxValue + 1));
// ---- S5TIME ----
[Theory]
[InlineData(0x0000, 0)]
[InlineData(0x0999, 9_990)] // 999 × 10 ms
[InlineData(0x1500, 50_000)] // 500 × 100 ms
[InlineData(0x2127, 127_000)] // W#16#2127 = 127 s (Siemens docs)
[InlineData(0x3999, 9_990_000)] // 999 × 10 s, the maximum
[InlineData(0xC127, 1_270)] // bits 15-14 are ignored (base 00, 127 × 10 ms)
public void S5Time_decode_known_vectors(int word, int ms) =>
Decode(S7DataType.S5Time, [(byte)(word >> 8), (byte)word]).ShouldBe(ms);
[Theory]
[InlineData(0, 0x0000)]
[InlineData(9_990, 0x0999)] // finest base that fits
[InlineData(10_000, 0x1100)] // 100 × 100 ms
[InlineData(127_000, 0x2127)]
[InlineData(9_990_000, 0x3999)]
public void S5Time_encode_picks_the_finest_base_that_fits(int ms, int word) =>
Encode(S7DataType.S5Time, ms).ShouldBe([(byte)(word >> 8), (byte)word]);
[Fact]
public void S5Time_non_bcd_nibble_is_rejected_on_read() =>
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.S5Time, [0x01, 0x2A]));
[Fact]
public void S5Time_unrepresentable_writes_are_refused()
{
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 9_990_001)); // above max
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, -10));
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 1_000_500)); // no base holds it exactly
}
[Fact]
public void S5Time_round_trips_every_representable_value_in_a_sample()
{
foreach (var ms in new[] { 10, 990, 9_990, 10_000, 99_900, 100_000, 999_000, 1_000_000, 9_990_000 })
Decode(S7DataType.S5Time, Encode(S7DataType.S5Time, ms)).ShouldBe(ms);
}
// ---- type mapping ----
[Theory]
[InlineData(S7DataType.WString, DriverDataType.String)]
[InlineData(S7DataType.Char, DriverDataType.String)]
[InlineData(S7DataType.WChar, DriverDataType.String)]
[InlineData(S7DataType.Dtl, DriverDataType.DateTime)]
[InlineData(S7DataType.Date, DriverDataType.DateTime)]
[InlineData(S7DataType.Tod, DriverDataType.Int32)]
[InlineData(S7DataType.Time, DriverDataType.Int32)]
[InlineData(S7DataType.S5Time, DriverDataType.Int32)]
public async Task Discovery_maps_new_types_onto_existing_driver_types(S7DataType dt, DriverDataType expected)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Tag(dt, 10) with { Name = "X" }),
};
using var drv = new S7Driver(opts, "s7-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single().Attr.DriverDataType.ShouldBe(expected);
}
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
// ---- TagConfig ----
[Fact]
public void TagConfig_accepts_wstring_length_up_to_16382_but_string_stays_254()
{
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16382}""", "p", out var d).ShouldBeTrue();
d.DataType.ShouldBe(S7DataType.WString);
d.StringLength.ShouldBe(16382);
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16383}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", "p", out _).ShouldBeFalse();
S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"WString","stringLength":99999}""")
.ShouldHaveSingleItem().ShouldContain("16382");
}
[Theory]
[InlineData("Char")]
[InlineData("WChar")]
[InlineData("Dtl")]
[InlineData("Date")]
[InlineData("Tod")]
[InlineData("Time")]
[InlineData("S5Time")]
public void TagConfig_round_trips_every_new_type_name(string name)
{
var dt = Enum.Parse<S7DataType>(name);
var cfg = S7TagDefinitionFactory.ToTagConfig(Tag(dt));
S7TagDefinitionFactory.FromTagConfig(cfg, "p", out var back).ShouldBeTrue();
back.DataType.ShouldBe(dt);
}
// ---- over the seam: status mapping ----
[Fact]
public async Task Driver_reads_writes_and_maps_range_errors_without_degrading_health()
{
var plc = new MemoryS7Plc();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("Ws", "DB1.DBB0", S7DataType.WString, StringLength: 4),
new S7TagDefinition("Dtl", "DB1.DBB100", S7DataType.Dtl),
new S7TagDefinition("S5", "DB1.DBB120", S7DataType.S5Time)),
};
using var drv = new S7Driver(opts, "s7-codec-e2e", new MemoryS7PlcFactory(plc));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
// Good write then read-back.
(await drv.WriteAsync([new WriteRequest("Ws", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
plc.Mem.Take(12).ToArray().ShouldBe([0, 4, 0, 4, 0, 0x61, 0, 0x62, 0, 0x63, 0, 0x64]);
var r = await drv.ReadAsync(["Ws"], ct);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe("abcd");
// Over-length write → BadOutOfRange, nothing written, health unharmed.
(await drv.WriteAsync([new WriteRequest("Ws", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
plc.Mem.Skip(5).Take(1).Single().ShouldBe((byte)0x61);
// Uninitialised DTL (all zero) reads BadOutOfRange, not a comms fault; the sibling stays Good.
var reads = await drv.ReadAsync(["Dtl", "Ws"], ct);
reads[0].StatusCode.ShouldBe(0x803C0000u);
reads[1].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// S5TIME value that fits no base → BadOutOfRange; wrong-shape value → BadTypeMismatch.
(await drv.WriteAsync([new WriteRequest("S5", 15)], ct))[0].StatusCode.ShouldBe(0x803C0000u);
(await drv.WriteAsync([new WriteRequest("S5", "soon")], ct))[0].StatusCode.ShouldBe(0x80740000u);
(await drv.WriteAsync([new WriteRequest("S5", 127_000)], ct))[0].StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S5"], ct))[0].Value.ShouldBe(127_000);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- fakes ----
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc;
}
/// <summary>Single-DB memory-backed fake: block reads/writes address one flat byte array.</summary>
internal sealed class MemoryS7Plc : IS7Plc
{
public byte[] Mem { get; } = new byte[512];
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException();
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) =>
Task.FromResult(Mem.AsSpan(start, count).ToArray());
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> items, CancellationToken ct) => throw new NotSupportedException();
public Task WriteAsync(string address, object value, CancellationToken ct) => throw new NotSupportedException();
public Task WriteBytesAsync(S7NetDataType area, int db, int start, byte[] value, CancellationToken ct)
{
value.CopyTo(Mem, start);
return Task.CompletedTask;
}
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
}
@@ -194,18 +194,28 @@ public sealed class TwinCATArrayCodecTests
}
/// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
/// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same
/// CLR shape a scalar publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c> (<c>ARRAY [0..12] OF TIME</c>)
/// begins <c>0, 10, 20</c> ms.
/// </summary>
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_duration_elements_as_double_ms(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new double[] { 0, 10, 20 });
/// <summary>DATE / DT elements decode to UTC DateTime from seconds since 1970.</summary>
[Theory]
[InlineData(TwinCATDataType.Date)]
[InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new uint[] { 0, 10, 20 });
public void Decode_date_elements_as_utc_datetime(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 60, 0, 0, 0], new TwinCATArrayLayout(2, 4), utf8: false)
.ShouldBe(new[]
{
new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(1970, 1, 1, 0, 1, 0, DateTimeKind.Utc),
});
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[Fact]
@@ -93,8 +93,8 @@ public sealed class TwinCATDriverTests
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
// IEC TIME/DATE/DT/TOD surface as their raw UDINT counter (Driver.TwinCAT-002).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
// IEC TIME/TOD surface as Double ms, DATE/DT as DateTime (#306).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
}
/// <summary>Verifies that status mapper covers known ADS error codes.</summary>
@@ -21,17 +21,16 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
/// <summary>Verifies that IEC time types map to uint32 raw counters.</summary>
/// <summary>IEC time types surface as native values (#306): TIME/TOD as Double ms, DATE/DT as DateTime.</summary>
[Fact]
public void Iec_time_types_map_to_uint32_raw_counter()
public void Iec_time_types_map_to_native_types()
{
// Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
// The next implementer who wants to decode them needs to see this mapping is intentional.
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
// The raw counter stays reachable by declaring UDInt.
TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
}
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
@@ -191,6 +191,35 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>A refused temporal write (#306) surfaces a status through WriteAsync, never an exception.</summary>
[Theory]
[InlineData(TwinCATDataType.TimeOfDay, 86_400_000d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.Time, -1d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.DateTime, "TimeSpan", TwinCATStatusMapper.BadTypeMismatch)]
public async Task Refused_temporal_write_surfaces_status_not_exception(
TwinCATDataType type, object value, uint expected)
{
var (drv, factory) = NewDriver(new TwinCATTagDefinition("T", "ads://5.23.91.23.1.1:851", "MAIN.T", type));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new ConvertingFake();
if (value is "TimeSpan") value = TimeSpan.FromSeconds(1);
var results = await drv.WriteAsync([new WriteRequest("T", value)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(expected);
}
// Runs the production write conversion the fake otherwise skips.
private sealed class ConvertingFake : FakeTwinCATClient
{
public override Task<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
AdsTwinCATClient.ConvertForWrite(type, value);
return base.WriteValueAsync(symbolPath, type, bitIndex, value, ct);
}
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Write_exception_surfaces_BadCommunicationError()
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "ads://5.23.91.23.1.1:851";
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and holds a connected client.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
var firstClient = factory.Clients[0];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,83 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>#306 — TIME / DATE / DT / TOD decode and encode round trips on known values.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTemporalCodecTests
{
private static readonly DateTime Dt = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Dt_decodes_seconds_since_1970_to_utc()
{
var raw = (uint)(Dt - DateTime.UnixEpoch).TotalSeconds;
var v = (DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.DateTime, raw);
v.ShouldBe(Dt);
v.Kind.ShouldBe(DateTimeKind.Utc);
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, v).ShouldBe(raw);
}
[Fact]
public void Date_round_trips_and_truncates_to_day()
{
var day = new DateTime(2026, 10, 8, 0, 0, 0, DateTimeKind.Utc);
var raw = TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day);
((DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.Date, raw)).ShouldBe(day);
TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day.AddHours(13)).ShouldBe(raw);
}
[Fact]
public void Tod_round_trips_milliseconds()
{
const uint ms = (12 * 3600 + 30 * 60 + 15) * 1000 + 250; // TOD#12:30:15.250
TwinCATTemporalCodec.Decode(TwinCATDataType.TimeOfDay, ms).ShouldBe(45_015_250d);
TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, 45_015_250d).ShouldBe(ms);
}
[Fact]
public void Time_round_trips_milliseconds_and_accepts_timespan()
{
const uint ms = ((1 * 60 + 2) * 60 + 3) * 1000 + 4; // T#1h2m3s4ms
TwinCATTemporalCodec.Decode(TwinCATDataType.Time, ms).ShouldBe(3_723_004d);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 3_723_004d).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, new TimeSpan(0, 1, 2, 3, 4)).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 5u).ShouldBe(5u); // raw integer still writes
}
[Theory]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(5_000_000_000d)]
public void Time_encode_refuses_out_of_range(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, value));
[Theory]
[InlineData(86_400_000d)]
[InlineData(-1d)]
public void Tod_encode_refuses_a_full_day_or_more(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, value));
[Fact]
public void Dt_before_1970_is_refused()
=> Should.Throw<OverflowException>(() =>
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
[Fact]
public void Wrong_shape_is_a_type_mismatch()
{
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, Dt));
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, TimeSpan.FromSeconds(1)));
}
[Fact]
public void Client_write_conversion_routes_temporal_types_through_the_codec()
{
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Time, 1000d).ShouldBe(1000u);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.DateTime, Dt)
.ShouldBe((uint)(Dt - DateTime.UnixEpoch).TotalSeconds);
// Authored UDInt against a TIME symbol keeps the raw counter.
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.UDInt, 1000u).ShouldBe(1000u);
}
}