fix(modbus): refusals are per-tag Bad, batch health, generation-stamped lifecycle, write status mapping
H4: exception 01/02/03 (and undecodable data) are device refusals - per-tag Bad with a once-per-tag Warning, no Degraded, and they advance LastSuccessfulRead (the device answered), so an all-refused driver no longer trips the 90 s watchdog. 04/05/06 still degrade (answered); 0A/0B degrade without advancing. Health is one worst-wins write per batch instead of last-writer-wins per tag. A comms failure on one unit fails the rest of that unit's batch fast; an unreachable endpoint fails the whole batch fast. H7: write transport failures map to BadCommunicationError, value overflow and array length mismatch to BadOutOfRange, conversion failures to BadTypeMismatch; writes report write-side health (a good write or a refusal clears a write-side Degraded; a good read does not). Lifecycle: every health write and the transport/table publish carry a generation; a superseded init releases its transport and publishes nothing, a read straddling a re-init cannot degrade the new transport, and the shutdown's final write cannot clobber a newer init. The tag table is build-and-swapped (re-init no longer merges into the old table). Teardown joins the probe/re-probe loops (bounded); a probe answer advances LastSuccessfulRead and an exception PDU from the device counts as Running. Transports latch dispose atomically and never reconnect after it. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport whose every send is answered by a test-supplied handler; falls back to the
|
||||
/// register/coil bank fake (<see cref="ModbusDriverTests.FakeTransport"/>) when the handler returns null.
|
||||
/// Counts sends per unit and dispose calls; the connect can be blocked on a gate.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedTransport : IModbusTransport
|
||||
{
|
||||
private readonly ConcurrentDictionary<byte, int> _sendsByUnit = new();
|
||||
private int _disposeCount;
|
||||
|
||||
/// <summary>The bank answering un-scripted sends.</summary>
|
||||
public ModbusDriverTests.FakeTransport Bank { get; } = new();
|
||||
|
||||
/// <summary>Per-send handler; return null to fall through to <see cref="Bank"/>.</summary>
|
||||
public Func<byte, byte[], CancellationToken, Task<byte[]>?>? Handler { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="ConnectAsync"/> waits for it before completing.</summary>
|
||||
public TaskCompletionSource? ConnectGate { get; set; }
|
||||
|
||||
/// <summary>Signalled when <see cref="ConnectAsync"/> is entered.</summary>
|
||||
public TaskCompletionSource ConnectEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
||||
|
||||
public int SendsTo(byte unit) => _sendsByUnit.TryGetValue(unit, out var n) ? n : 0;
|
||||
|
||||
public int TotalSends => _sendsByUnit.Values.Sum();
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ConnectEntered.TrySetResult();
|
||||
if (ConnectGate is { } gate) await gate.Task.WaitAsync(ct);
|
||||
}
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
_sendsByUnit.AddOrUpdate(unitId, 1, (_, n) => n + 1);
|
||||
var scripted = Handler?.Invoke(unitId, pdu, ct);
|
||||
return scripted ?? Bank.SendAsync(unitId, pdu, ct);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Interlocked.Increment(ref _disposeCount);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user