STAB-21 ConnectionBackoff in EnsureConnectedAsync; STAB-22 write-pass accumulator with a write-side Faulted that a clean read does not erase; _health on the Volatile idiom; S7-016 contiguous array write (Bool arrays stay refused). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// #610 item 2 (S7-016): array write round trip against the python-snap7 S7-1500 profile. Skips
|
||||
/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 1024-byte DB1 has no seed
|
||||
/// there), so the writes cannot disturb another test's seeds.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500ArrayWriteTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private static S7DriverOptions Options(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
CpuType = S7CpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("I16Arr", "DB1.DBW700", S7DataType.Int16, Writable: true, ArrayCount: 4),
|
||||
new S7TagDefinition("F32Arr", "DB1.DBD720", S7DataType.Float32, Writable: true, ArrayCount: 3)),
|
||||
};
|
||||
|
||||
/// <summary>Writes a non-trivial Int16[4] and Float32[3], then reads each back element-for-element.</summary>
|
||||
[Fact]
|
||||
public async Task Array_write_then_read_round_trips_through_real_S7comm()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
await using var drv = new S7Driver(Options(sim.Host, sim.Port), "s7-arr-write");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var i16 = new short[] { 111, -222, 3333, -4444 };
|
||||
var f32 = new[] { 1.5f, -2.25f, 1234.5f };
|
||||
var res = await drv.WriteAsync(
|
||||
[new WriteRequest("I16Arr", i16), new WriteRequest("F32Arr", f32)],
|
||||
TestContext.Current.CancellationToken);
|
||||
res.Select(r => r.StatusCode).ShouldAllBe(c => c == 0u);
|
||||
|
||||
var read = await drv.ReadAsync(["I16Arr", "F32Arr"], TestContext.Current.CancellationToken);
|
||||
read.Select(r => r.StatusCode).ShouldAllBe(c => c == 0u);
|
||||
((short[])read[0].Value!).ShouldBe(i16);
|
||||
((float[])read[1].Value!).ShouldBe(f32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using S7.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
using S7DataItem = global::S7.Net.Types.DataItem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #602 / #610 S7 items: array write path (S7-016), connect backoff (STAB-21), the write-pass
|
||||
/// worst-state-wins accumulator (STAB-22) and the factory logger (CONV-3). All against an
|
||||
/// in-memory <see cref="IS7Plc"/> — no live PLC, no fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7ArrayWriteAndHealthTests
|
||||
{
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
private const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
private static S7TagDefinition Arr(S7DataType dt, string addr, int count, bool writable = true) =>
|
||||
new("Arr", addr, dt, Writable: writable, ArrayCount: count);
|
||||
|
||||
private static S7DriverOptions Options(params S7TagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(tags),
|
||||
};
|
||||
|
||||
// ---- pure codec ----
|
||||
|
||||
/// <summary>Encode is the exact inverse of <see cref="S7Driver.DecodeArrayBlock"/> for every supported element type.</summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Byte, "DB1.DBB0")]
|
||||
[InlineData(S7DataType.Int16, "DB1.DBW0")]
|
||||
[InlineData(S7DataType.UInt16, "DB1.DBW0")]
|
||||
[InlineData(S7DataType.Int32, "DB1.DBD0")]
|
||||
[InlineData(S7DataType.UInt32, "DB1.DBD0")]
|
||||
[InlineData(S7DataType.Float32, "DB1.DBD0")]
|
||||
public void EncodeArrayBlock_round_trips_through_DecodeArrayBlock(S7DataType dt, string addrText)
|
||||
{
|
||||
var tag = Arr(dt, addrText, 3);
|
||||
var addr = S7AddressParser.Parse(addrText);
|
||||
object source = dt switch
|
||||
{
|
||||
S7DataType.Byte => new byte[] { 1, 200, 255 },
|
||||
S7DataType.Int16 => new short[] { -1, 0, 32000 },
|
||||
S7DataType.UInt16 => new ushort[] { 1, 0, 65535 },
|
||||
S7DataType.Int32 => new[] { -5, 0, int.MaxValue },
|
||||
S7DataType.UInt32 => new uint[] { 1, 0, uint.MaxValue },
|
||||
_ => new[] { -1.5f, 0f, 3.25f },
|
||||
};
|
||||
|
||||
var block = S7Driver.EncodeArrayBlock(tag, addr, source);
|
||||
|
||||
block.Length.ShouldBe(3 * S7Driver.ElementByteSize(addr.Size));
|
||||
S7Driver.DecodeArrayBlock(tag, addr, block).ShouldBe(source);
|
||||
}
|
||||
|
||||
/// <summary>Big-endian layout is pinned byte-for-byte for Int16.</summary>
|
||||
[Fact]
|
||||
public void EncodeArrayBlock_is_big_endian()
|
||||
{
|
||||
var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2);
|
||||
S7Driver.EncodeArrayBlock(tag, S7AddressParser.Parse("DB1.DBW0"), new short[] { 0x0102, -2 })
|
||||
.ShouldBe(new byte[] { 0x01, 0x02, 0xFF, 0xFE });
|
||||
}
|
||||
|
||||
/// <summary>A non-array, a string, a wrong length and an unconvertible element are client errors, not comms faults.</summary>
|
||||
[Fact]
|
||||
public void EncodeArrayBlock_rejects_wrongly_shaped_values()
|
||||
{
|
||||
var tag = Arr(S7DataType.Int16, "DB1.DBW0", 2);
|
||||
var addr = S7AddressParser.Parse("DB1.DBW0");
|
||||
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, (short)1));
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, "ab"));
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, new short[] { 1 }));
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, new[] { 1, 2, 3 }));
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, new object[] { 1, 70000 })); // overflow
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => S7Driver.EncodeArrayBlock(tag, addr, new object[] { 1, "x" })); // format
|
||||
}
|
||||
|
||||
/// <summary>Bool and wide-type arrays are refused with NotSupported (BadNotSupported on the wire).</summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Bool, "DB1.DBX0.0")]
|
||||
[InlineData(S7DataType.Int64, "DB1.DBB0")]
|
||||
[InlineData(S7DataType.String, "DB1.DBB0")]
|
||||
public void EncodeArrayBlock_refuses_bool_and_wide_arrays(S7DataType dt, string addrText)
|
||||
{
|
||||
Should.Throw<NotSupportedException>(() => S7Driver.EncodeArrayBlock(
|
||||
Arr(dt, addrText, 2), S7AddressParser.Parse(addrText), new object[] { 1, 2 }));
|
||||
}
|
||||
|
||||
// ---- WriteAsync array path ----
|
||||
|
||||
/// <summary>
|
||||
/// A valid array write issues exactly ONE contiguous WriteBytes at the tag's DB/offset
|
||||
/// (S7-016) and reports Good. The bytes are the decode inverse, so a following read returns the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_array_issues_one_contiguous_block_write()
|
||||
{
|
||||
var factory = new RecordingFactory();
|
||||
using var drv = new S7Driver(Options(Arr(S7DataType.Int16, "DB5.DBW10", 3)), "s7-arr-write", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var res = await drv.WriteAsync([new WriteRequest("Arr", new short[] { 1, 2, 3 })], TestContext.Current.CancellationToken);
|
||||
|
||||
res[0].StatusCode.ShouldBe(0u);
|
||||
var w = factory.Created[0].BlockWrites.ShouldHaveSingleItem();
|
||||
w.Area.ShouldBe(S7NetDataType.DataBlock);
|
||||
w.Db.ShouldBe(5);
|
||||
w.Start.ShouldBe(10);
|
||||
w.Bytes.ShouldBe(new byte[] { 0, 1, 0, 2, 0, 3 });
|
||||
factory.Created[0].ScalarWrites.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A wrong-length array is BadTypeMismatch, writes nothing and leaves health untouched; the
|
||||
/// positive control is the valid write in the same batch landing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_array_with_wrong_length_is_BadTypeMismatch_and_not_a_health_event()
|
||||
{
|
||||
var factory = new RecordingFactory();
|
||||
using var drv = new S7Driver(Options(Arr(S7DataType.Int16, "DB1.DBW0", 3)), "s7-arr-len", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var res = await drv.WriteAsync(
|
||||
[new WriteRequest("Arr", new short[] { 1, 2 }), new WriteRequest("Arr", new short[] { 7, 8, 9 })],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
res[0].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
res[1].StatusCode.ShouldBe(0u);
|
||||
factory.Created[0].BlockWrites.Count.ShouldBe(1); // only the valid one reached the wire
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---- STAB-21 connect backoff ----
|
||||
|
||||
/// <summary>
|
||||
/// A dead PLC costs ONE connect attempt per backoff window, not one per read; the window grows
|
||||
/// (1 s then 2 s), and a successful connect clears it so recovery is immediate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dead_plc_costs_one_connect_attempt_per_backoff_window()
|
||||
{
|
||||
var factory = new RecordingFactory();
|
||||
using var drv = new S7Driver(Options(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16)), "s7-backoff", factory);
|
||||
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
drv.BackoffClock = () => now;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
factory.Created.Count.ShouldBe(1);
|
||||
|
||||
// Drop the link; every subsequent open is refused.
|
||||
factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
|
||||
factory.OpenThrows = new PlcException(ErrorCode.ConnectionError, "down");
|
||||
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
|
||||
|
||||
// First attempt after the drop: really tries (and fails), opening a 1 s window.
|
||||
(await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldNotBe(0u);
|
||||
factory.Created.Count.ShouldBe(2);
|
||||
|
||||
// Ten more reads inside the window: no further connect attempt, still a Bad result.
|
||||
for (var i = 0; i < 10; i++)
|
||||
(await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldNotBe(0u);
|
||||
factory.Created.Count.ShouldBe(2);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Window elapses -> exactly one more attempt (fails -> 2 s window).
|
||||
now += TimeSpan.FromSeconds(1.1);
|
||||
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
|
||||
factory.Created.Count.ShouldBe(3);
|
||||
now += TimeSpan.FromSeconds(1.1); // inside the doubled window
|
||||
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
|
||||
factory.Created.Count.ShouldBe(3);
|
||||
|
||||
// PLC returns; once the window passes the next read reconnects and the window is cleared.
|
||||
factory.OpenThrows = null;
|
||||
now += TimeSpan.FromSeconds(5);
|
||||
(await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
factory.Created.Count.ShouldBe(4);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// A fresh drop reconnects immediately (no residual window).
|
||||
factory.Created[3].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped again");
|
||||
await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
|
||||
(await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
factory.Created.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ---- STAB-22 write-pass worst-state-wins ----
|
||||
|
||||
private static async Task<(S7Driver Drv, RecordingFactory Factory)> TwoTagDriver()
|
||||
{
|
||||
var factory = new RecordingFactory();
|
||||
var drv = new S7Driver(Options(
|
||||
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: true),
|
||||
new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: true),
|
||||
new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: true)), "s7-writepass", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access-denied (Faulted) followed by a generic failure (Degraded) and a success in ONE batch
|
||||
/// must report Faulted with the access-denied message - not whichever item came last.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_batch_reports_worst_state_not_last_item()
|
||||
{
|
||||
var (drv, factory) = await TwoTagDriver();
|
||||
using var _ = drv;
|
||||
factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed");
|
||||
factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch");
|
||||
|
||||
var res = await drv.WriteAsync(
|
||||
[new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
res[2].StatusCode.ShouldBe(0u);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Faulted);
|
||||
h.LastError.ShouldNotBeNull().ShouldContain("PUT/GET");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A clean READ pass must not erase the write-side Faulted; the next write that succeeds clears
|
||||
/// it, after which a read pass reports Healthy again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_pass_does_not_erase_write_fault_until_a_write_succeeds()
|
||||
{
|
||||
var (drv, factory) = await TwoTagDriver();
|
||||
using var _ = drv;
|
||||
var plc = factory.Created[0];
|
||||
plc.WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed");
|
||||
await drv.WriteAsync([new WriteRequest("A", (ushort)1)], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
|
||||
// Clean read of a different tag - reads work against a write-protected CPU.
|
||||
var read = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
|
||||
read[0].StatusCode.ShouldBe(0u);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Faulted); // not erased
|
||||
h.LastSuccessfulRead.ShouldNotBeNull(); // but the read still advanced the watchdog evidence
|
||||
|
||||
// A write that gets through retires the fault ...
|
||||
await drv.WriteAsync([new WriteRequest("B", (ushort)2)], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// ... and the next read pass stays Healthy (the sticky state is really gone).
|
||||
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A transient (Degraded) write failure is still erased by a clean read, as before.</summary>
|
||||
[Fact]
|
||||
public async Task Degraded_write_failure_is_still_cleared_by_a_clean_read()
|
||||
{
|
||||
var (drv, factory) = await TwoTagDriver();
|
||||
using var _ = drv;
|
||||
factory.Created[0].WriteFaults["DB1.DBW0"] = new IOException("glitch");
|
||||
await drv.WriteAsync([new WriteRequest("A", (ushort)1)], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---- CONV-3 ----
|
||||
|
||||
/// <summary>
|
||||
/// The registered factory builds drivers on the host logger: a failed connect reaches the
|
||||
/// supplied <see cref="ILoggerFactory"/>. Before CONV-3 the factory took no logger and the driver ran on the null logger.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Factory_threads_the_logger_factory_into_the_driver()
|
||||
{
|
||||
const string Json = """{"host":"192.0.2.1","timeoutMs":200}""";
|
||||
|
||||
var withLogger = new CapturingLoggerFactory();
|
||||
var registry = new DriverFactoryRegistry();
|
||||
S7DriverFactoryExtensions.Register(registry, withLogger);
|
||||
var drv = registry.TryGet(S7DriverFactoryExtensions.DriverTypeName)!("s7-logs", Json);
|
||||
try { await drv.InitializeAsync(Json, TestContext.Current.CancellationToken); } catch { /* unreachable host by design */ }
|
||||
withLogger.Messages.ShouldContain(m => m.Contains("connect failed", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private sealed class CapturingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public ILogger CreateLogger(string categoryName) => new Capture(this);
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class Capture(CapturingLoggerFactory owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (owner.Messages) owner.Messages.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- in-memory PLC ----
|
||||
|
||||
private sealed class RecordingFactory : IS7PlcFactory
|
||||
{
|
||||
public List<RecordingPlc> Created { get; } = [];
|
||||
|
||||
/// <summary>When set, every connection created from now on throws this from OpenAsync.</summary>
|
||||
public Exception? OpenThrows { get; set; }
|
||||
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
{
|
||||
var plc = new RecordingPlc { OpenThrowsAlways = OpenThrows };
|
||||
Created.Add(plc);
|
||||
return plc;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPlc : IS7Plc
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public Exception? OpenThrowsAlways { get; init; }
|
||||
public Exception? NextReadThrows { get; set; }
|
||||
public Dictionary<string, Exception> WriteFaults { get; } = new(StringComparer.Ordinal);
|
||||
public List<(S7NetDataType Area, int Db, int Start, byte[] Bytes)> BlockWrites { get; } = [];
|
||||
public int ScalarWrites { get; private set; }
|
||||
|
||||
public Task OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (OpenThrowsAlways is { } ex) throw ex;
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Close() => IsConnected = false;
|
||||
|
||||
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NextReadThrows is { } ex) { NextReadThrows = null; throw ex; }
|
||||
return Task.FromResult<object?>((ushort)42);
|
||||
}
|
||||
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NextReadThrows is { } ex) { NextReadThrows = null; throw ex; }
|
||||
return Task.FromResult(new byte[count]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException("not needed");
|
||||
|
||||
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
|
||||
{
|
||||
ScalarWrites++;
|
||||
if (WriteFaults.TryGetValue(address, out var ex)) throw ex;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
|
||||
{
|
||||
BlockWrites.Add((area, db, startByteAdr, value));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void Dispose() => IsConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,8 @@ public sealed class S7DriverReconnectTests
|
||||
{
|
||||
var factory = new FakeS7PlcFactory();
|
||||
using var drv = new S7Driver(Options(), "s7-reopen-fail", factory);
|
||||
var clock = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
drv.BackoffClock = () => clock;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Drop the live socket, and make the NEXT-created connection fail to open.
|
||||
@@ -161,7 +163,9 @@ public sealed class S7DriverReconnectTests
|
||||
down[0].StatusCode.ShouldNotBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// PLC comes back; the next call reopens cleanly and the tag reads Good.
|
||||
// PLC comes back, and the 1 s STAB-21 backoff window the failed reopen opened has elapsed;
|
||||
// the next call reopens cleanly and the tag reads Good.
|
||||
clock += TimeSpan.FromSeconds(2);
|
||||
var up = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
|
||||
up[0].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
@@ -226,24 +226,42 @@ public sealed class S7DriverScaffoldTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
// ── Driver.S7-015 — writable array tag must fail fast at init ────────────────────────
|
||||
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
|
||||
//
|
||||
// Array reads are implemented (ReadArrayAsync / DecodeArrayBlock), but WriteOneAsync has
|
||||
// no WriteArrayAsync path. Without the init guard a writable array node is discovered and
|
||||
// accepted, then every write returns BadCommunicationError (InvalidCastException from
|
||||
// BoxValueForWrite receiving a typed array). Fail fast at init instead.
|
||||
// Non-bool arrays now write as one contiguous block (S7-016, EncodeArrayBlock). A Bool array
|
||||
// write would be byte-granular and clobber the neighbouring bits of every byte it touches, so
|
||||
// a writable Bool array is still refused at init with an informative message.
|
||||
|
||||
/// <summary>Verifies that a writable non-wide array (e.g., Int16[4]) is rejected at init
|
||||
/// with a clear "array writes not yet supported" message — Driver.S7-015.</summary>
|
||||
/// <summary>Verifies that a writable Bool array is rejected at init.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ArrTag");
|
||||
ex.Message.ShouldContain("Bool array", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
|
||||
/// must be the TCP connect (unreachable host), not the array guard (S7-016).</summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Bool, "DB1.DBX0.0", 4)]
|
||||
[InlineData(S7DataType.Byte, "DB1.DBB0", 8)]
|
||||
[InlineData(S7DataType.Int16, "DB1.DBW0", 4)]
|
||||
[InlineData(S7DataType.UInt16, "DB1.DBW0", 4)]
|
||||
[InlineData(S7DataType.Int32, "DB1.DBD0", 2)]
|
||||
[InlineData(S7DataType.UInt32, "DB1.DBD0", 2)]
|
||||
[InlineData(S7DataType.Float32,"DB1.DBD0", 2)]
|
||||
public async Task Initialize_rejects_writable_array_tag_with_NotSupportedException(
|
||||
public async Task Initialize_accepts_writable_non_bool_array_tag(
|
||||
S7DataType dt, string addr, int count)
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
@@ -254,13 +272,9 @@ public sealed class S7DriverScaffoldTests
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
var ex = await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ArrTag");
|
||||
ex.Message.ShouldContain("array", Case.Insensitive);
|
||||
ex.Message.ShouldContain("write", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
ex.ShouldNotBeOfType<NotSupportedException>();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a read-only (Writable=false) array passes the init guard —
|
||||
|
||||
Reference in New Issue
Block a user