4a3d7a9361
U1: a tag's addressString (Modicon / mnemonic / DL205 V-memory / MELSEC D-X-Y grammar) now resolves through ModbusAddressParser with the DRIVER's Family and MelsecSubFamily at every initialize and is authoritative; the family-unaware gates (deploy Inspect, AdminUI parse consult) accept what some family resolves. The tag editor gets an address-text field that resolves with the owning driver's family and persists both the string and the resolved region/offset/type fields; a hand edit drops the string. DisableFC23 now gates FC23: by default a 2..121-register holding write is one FC23 write + read-back (a unit answering exception 01, or failing FC23 at the transport, moves to FC16 for the rest of the generation); RTU framing deframes the FC23 reply. Live-checked: the pymodbus fixture implements FC23. Also: the AdminUI driver form serialises ModbusDriverOptions (TimeSpan strings), which the factory DTO never read - every timing field on the form was dead. The DTO now accepts both shapes (*Ms keys win). Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
163 lines
7.7 KiB
C#
163 lines
7.7 KiB
C#
using System.Diagnostics;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Comms-loss → recovery against the real pymodbus <c>standard</c> fixture, with a loopback
|
|
/// <see cref="TcpRelay"/> in front of it so the device can disappear and come back without touching the
|
|
/// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband
|
|
/// recovery publish).
|
|
/// </summary>
|
|
[Collection(ModbusSimulatorCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Device", "Standard")]
|
|
public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
|
{
|
|
private const uint StatusGood = 0u;
|
|
|
|
private void SkipUnlessStandardLive()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
|
if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase))
|
|
Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address).");
|
|
}
|
|
|
|
private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new()
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = relay.Port,
|
|
UnitId = 1,
|
|
Timeout = TimeSpan.FromSeconds(2),
|
|
RawTags = ModbusRawTags.Entries(tags),
|
|
Probe = new ModbusProbeOptions { Enabled = false },
|
|
};
|
|
|
|
private static async Task<T> WaitFor<T>(Func<Task<T>> probe, Func<T, bool> done, TimeSpan budget)
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
T last;
|
|
do
|
|
{
|
|
last = await probe();
|
|
if (done(last)) return last;
|
|
await Task.Delay(200, TestContext.Current.CancellationToken);
|
|
} while (sw.Elapsed < budget);
|
|
return last;
|
|
}
|
|
|
|
/// <summary>
|
|
/// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the
|
|
/// transport must reconnect ON ITS OWN once the device is back — before this fix every later call
|
|
/// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the
|
|
/// driver.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit()
|
|
{
|
|
SkipUnlessStandardLive();
|
|
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
|
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false);
|
|
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3");
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
|
good[0].StatusCode.ShouldBe(StatusGood);
|
|
good[0].Value.ShouldBe((ushort)5);
|
|
|
|
relay.Cut();
|
|
for (var i = 0; i < 2; i++)
|
|
{
|
|
var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
|
bad[0].StatusCode.ShouldNotBe(StatusGood);
|
|
}
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
relay.Restore();
|
|
var recovered = await WaitFor(
|
|
() => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken),
|
|
r => r[0].StatusCode == StatusGood,
|
|
TimeSpan.FromSeconds(20));
|
|
recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back");
|
|
recovered[0].Value.ShouldBe((ushort)5);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// U1 (DisableFC23 wired): a multi-register write on the default (FC23 write + read-back) path
|
|
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
|
|
/// is moved to FC16 — and so does the forced-FC16 path. Uses the standard profile's scratch HR[200..209].
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(false, 200)]
|
|
[InlineData(true, 202)]
|
|
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool disableFc23, int address)
|
|
{
|
|
SkipUnlessStandardLive();
|
|
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
|
|
var opts = new ModbusDriverOptions
|
|
{
|
|
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), DisableFC23 = disableFc23,
|
|
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
|
};
|
|
await using var driver = new ModbusDriver(opts, "modbus-u1");
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var value = disableFc23 ? 12.25f : 6.5f;
|
|
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
|
|
{
|
|
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
|
|
w[0].StatusCode.ShouldBe(StatusGood);
|
|
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
|
|
r[0].Value.ShouldBe(value + i);
|
|
}
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
|
|
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
|
|
/// Positive control: the Bad publish during the outage is observed first.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value()
|
|
{
|
|
SkipUnlessStandardLive();
|
|
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
|
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16,
|
|
Writable: false, Deadband: 10.0);
|
|
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2");
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var published = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
|
driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot);
|
|
var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
|
try
|
|
{
|
|
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
|
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10)))
|
|
.ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish");
|
|
|
|
relay.Cut();
|
|
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
|
p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20)))
|
|
.ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)");
|
|
|
|
var badCount = published.Count;
|
|
relay.Restore();
|
|
var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()),
|
|
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30));
|
|
var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood);
|
|
recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband");
|
|
recovery.Value.ShouldBe((ushort)5);
|
|
}
|
|
finally
|
|
{
|
|
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
|
}
|
|
}
|
|
}
|