Pre-#150 a coalesced read failure recorded the FULL failed range as permanently prohibited. Healthy registers around the actual protected register stayed in per-tag mode forever (until ReinitializeAsync). The re-probe loop shipped in #151 retried the whole range as a single block, which would either succeed (clearing everything) or fail (changing nothing). Post-#150 the re-probe loop bisects multi-register prohibitions: - _autoProhibited refactored from Dictionary<key, DateTime> to Dictionary<key, ProhibitionState> where ProhibitionState carries LastProbedUtc + SplitPending. Multi-register prohibitions enter with SplitPending=true; single-register prohibitions enter with SplitPending=false (already minimal). - ReprobeLoopAsync delegates the per-pass work to RunReprobeOnceForTestAsync (also exposed for synchronous test driving). Each entry routes to BisectAndReprobeAsync (split-pending + multi-reg) or StraightReprobeAsync (single-reg / non-split-pending). - Bisection: split (start, end) at mid = (start+end)/2. Try (start, mid) and (mid+1, end) as separate coalesced reads. Each FAILED half re-enters the prohibition map with SplitPending = (its end > its start). SUCCEEDED halves vanish, freeing the planner to coalesce across them on the next scan. - Convergence: log2(span) re-probe ticks pin the prohibition to the actual single offending register(s). For a 100-register block with one protected address that's ~7 ticks. Tests (3 new ModbusCoalescingBisectionTests): - Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe — 11 tags 100..110 with protected address 105. After 4 re-probe passes the prohibition collapses from (100..110) → (100..105) → (103..105) → (105..105). - Bisection_Clears_When_Both_Halves_Are_Healthy — transient failure scenario; protection lifted before re-probe; both bisection halves succeed and the parent vanishes entirely. - Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail — TwoHoleTransport with protected addresses 102 + 108 in the same coalesced range. After bisection both halves still fail (each contains one of the protected addresses); the prohibition map grows to 2 entries. 236 + 3 = 239 unit tests green. Solution build clean.
1419 lines
67 KiB
C#
1419 lines
67 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text.Json;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
|
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
|
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
|
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
|
|
/// express them). Subscriptions overlay a polling loop via the shared
|
|
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
|
|
/// </remarks>
|
|
public sealed class ModbusDriver
|
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// #142 multi-unit-ID gateway support: per-tag UnitId override drives per-slave host
|
|
/// name surfacing through this method. The resilience pipeline keys breakers on the
|
|
/// returned host string, so a dead RTU slave behind an Ethernet gateway opens its own
|
|
/// breaker without tripping siblings on the same TCP socket.
|
|
/// </summary>
|
|
public string ResolveHost(string fullReference)
|
|
{
|
|
if (_tagsByName.TryGetValue(fullReference, out var tag))
|
|
return BuildSlaveHostName(ResolveUnitId(tag));
|
|
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
|
|
return HostName;
|
|
}
|
|
|
|
/// <summary>Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.</summary>
|
|
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
|
|
|
|
// Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
|
|
// the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
|
|
private readonly PollGroupEngine _poll;
|
|
private readonly string _driverInstanceId;
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
|
|
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
|
|
// display the PLC endpoint uniformly with Galaxy platforms/engines.
|
|
private readonly object _probeLock = new();
|
|
private HostState _hostState = HostState.Unknown;
|
|
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
|
private CancellationTokenSource? _probeCts;
|
|
private readonly ModbusDriverOptions _options;
|
|
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
|
|
|
private IModbusTransport? _transport;
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_transportFactory = transportFactory
|
|
?? (o => new ModbusTcpTransport(
|
|
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
|
keepAlive: o.KeepAlive,
|
|
idleDisconnect: o.IdleDisconnectTimeout,
|
|
reconnect: o.Reconnect));
|
|
_poll = new PollGroupEngine(
|
|
reader: ReadAsync,
|
|
onChange: (handle, tagRef, snapshot) =>
|
|
{
|
|
// #141 deadband filter: when configured on a tag, suppress publishes whose
|
|
// numeric distance from the last-published value is below the threshold.
|
|
if (!ShouldPublish(tagRef, snapshot)) return;
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
|
});
|
|
}
|
|
|
|
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
|
|
// the deadband filter. Stored as object so all numeric types share one map; the comparison
|
|
// does a typed cast inside.
|
|
private readonly Dictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
|
|
// that return a different value (so an HMI-side change doesn't get masked).
|
|
private readonly Dictionary<string, object?> _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly object _lastWrittenLock = new();
|
|
|
|
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
|
{
|
|
if (!_tagsByName.TryGetValue(tagRef, out var tag) || tag.Deadband is null) return true;
|
|
if (snapshot.Value is null) return true;
|
|
// Deadband only applies to numeric scalar types — array / Bool / String publishes
|
|
// unconditionally. Easier to special-case skip than to enumerate the supported types.
|
|
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
|
|
return true;
|
|
|
|
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
|
|
{
|
|
// First sample passes through unconditionally — the threshold can't be evaluated
|
|
// without a baseline. The publish lands and seeds the comparison.
|
|
_lastPublishedByRef[tagRef] = snapshot.Value;
|
|
return true;
|
|
}
|
|
|
|
var newD = Convert.ToDouble(snapshot.Value);
|
|
var oldD = Convert.ToDouble(prev);
|
|
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
|
|
|
|
_lastPublishedByRef[tagRef] = snapshot.Value;
|
|
return true;
|
|
}
|
|
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
public string DriverType => "Modbus";
|
|
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
_transport = _transportFactory(_options);
|
|
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
|
|
|
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
|
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
|
// Running transition before any register round-trip has happened.
|
|
if (_options.Probe.Enabled)
|
|
{
|
|
_probeCts = new CancellationTokenSource();
|
|
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
|
}
|
|
|
|
// #151 — start the auto-prohibition re-probe loop when the operator opted in.
|
|
if (_options.AutoProhibitReprobeInterval is not null)
|
|
{
|
|
_reprobeCts = new CancellationTokenSource();
|
|
_ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
await ShutdownAsync(cancellationToken);
|
|
await InitializeAsync(driverConfigJson, cancellationToken);
|
|
}
|
|
|
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
try { _probeCts?.Cancel(); } catch { }
|
|
_probeCts?.Dispose();
|
|
_probeCts = null;
|
|
|
|
try { _reprobeCts?.Cancel(); } catch { }
|
|
_reprobeCts?.Dispose();
|
|
_reprobeCts = null;
|
|
|
|
// #151 — clear the prohibition set on shutdown so an explicit operator restart
|
|
// (ReinitializeAsync) starts with a clean slate. The re-probe loop already retries
|
|
// automatically when enabled; the restart path is the manual escape hatch.
|
|
lock (_autoProhibitedLock) _autoProhibited.Clear();
|
|
|
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
|
|
|
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
|
_transport = null;
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
}
|
|
|
|
public DriverHealth GetHealth() => _health;
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var folder = builder.Folder("Modbus", "Modbus");
|
|
foreach (var t in _options.Tags)
|
|
{
|
|
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
|
FullName: t.Name,
|
|
DriverDataType: MapDataType(t.DataType),
|
|
IsArray: t.ArrayCount.HasValue,
|
|
ArrayDim: t.ArrayCount.HasValue ? (uint)t.ArrayCount.Value : null,
|
|
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: t.WriteIdempotent));
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IReadable ----
|
|
|
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var transport = RequireTransport();
|
|
var now = DateTime.UtcNow;
|
|
var results = new DataValueSnapshot[fullReferences.Count];
|
|
|
|
// #143 block-read coalescing: when MaxReadGap is non-zero, route eligible tags through
|
|
// the coalescing planner first. Tags it can't coalesce (arrays, coils, prohibited,
|
|
// unknown) fall through to the per-tag loop below with results[i] still default.
|
|
var coalesced = _options.MaxReadGap > 0
|
|
? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
|
|
: new HashSet<int>();
|
|
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
if (coalesced.Contains(i)) continue;
|
|
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
|
|
{
|
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
|
continue;
|
|
}
|
|
try
|
|
{
|
|
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
|
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
|
|
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
|
|
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
|
|
// tweaked at the panel could be silently re-suppressed when our client tried
|
|
// to restore it.
|
|
if (_options.WriteOnChangeOnly)
|
|
{
|
|
lock (_lastWrittenLock)
|
|
{
|
|
if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
|
|
_lastWrittenByRef.Remove(fullReferences[i]);
|
|
}
|
|
}
|
|
}
|
|
catch (ModbusException mex)
|
|
{
|
|
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
|
|
// as communication error so callers can distinguish it from tag-level faults.
|
|
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
|
{
|
|
var arrayCount = tag.ArrayCount ?? 1;
|
|
|
|
switch (tag.Region)
|
|
{
|
|
case ModbusRegion.Coils:
|
|
case ModbusRegion.DiscreteInputs:
|
|
{
|
|
// FC01 (Coils) / FC02 (DiscreteInputs). Auto-chunk when array count exceeds the
|
|
// coil cap — Modbus spec says ≤ 2000 bits per request; some devices cap lower
|
|
// (we trust the caller-provided MaxCoilsPerRead).
|
|
var fc = tag.Region == ModbusRegion.Coils ? (byte)0x01 : (byte)0x02;
|
|
var cap = _options.MaxCoilsPerRead == 0 ? (ushort)2000 : _options.MaxCoilsPerRead;
|
|
var unitId = ResolveUnitId(tag);
|
|
var bitmap = arrayCount <= cap
|
|
? await ReadBitBlockAsync(transport, unitId, fc, tag.Address, (ushort)arrayCount, ct).ConfigureAwait(false)
|
|
: await ReadBitBlockChunkedAsync(transport, unitId, fc, tag.Address, arrayCount, cap, ct).ConfigureAwait(false);
|
|
return DecodeBitArray(bitmap, arrayCount, tag.ArrayCount.HasValue);
|
|
}
|
|
case ModbusRegion.HoldingRegisters:
|
|
case ModbusRegion.InputRegisters:
|
|
{
|
|
var elementRegs = RegisterCount(tag);
|
|
var totalRegs = (ushort)(elementRegs * arrayCount);
|
|
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
|
// Affects long strings + arrays (FC03/04 > 125 regs is spec-forbidden; DL205 caps
|
|
// at 128, Mitsubishi Q caps at 64). Scalar non-string tags max out at 4 regs so
|
|
// the cap never triggers for them.
|
|
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
|
var unitId = ResolveUnitId(tag);
|
|
var data = totalRegs <= cap
|
|
? await ReadRegisterBlockAsync(transport, unitId, fc, tag.Address, totalRegs, ct).ConfigureAwait(false)
|
|
: await ReadRegisterBlockChunkedAsync(transport, unitId, fc, tag.Address, totalRegs, cap, ct).ConfigureAwait(false);
|
|
|
|
if (!tag.ArrayCount.HasValue)
|
|
return DecodeRegister(data, tag);
|
|
|
|
return DecodeRegisterArray(data, tag, elementRegs, arrayCount);
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode an FC01/FC02 coil-bitmap response into either a single bool (scalar tag) or a
|
|
/// bool[] of <paramref name="count"/> elements (array tag). Modbus packs coils LSB-first
|
|
/// within each byte, ascending address across bytes.
|
|
/// </summary>
|
|
private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
|
|
{
|
|
if (!isArray) return (bitmap[0] & 0x01) == 1;
|
|
var result = new bool[count];
|
|
for (var i = 0; i < count; i++)
|
|
result[i] = ((bitmap[i / 8] >> (i % 8)) & 0x01) == 1;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode an array of register-backed values from a contiguous block. Each element
|
|
/// occupies <paramref name="elementRegs"/> registers and is decoded with the same
|
|
/// codec the scalar path uses, sliced from its position in the block.
|
|
/// </summary>
|
|
private static object DecodeRegisterArray(byte[] data, ModbusTagDefinition tag, int elementRegs, int count)
|
|
{
|
|
var elementBytes = elementRegs * 2;
|
|
// Element type drives the array CLR type. Boxed into Array so the Read pipeline can
|
|
// surface it directly without a per-call type-switch on the caller side.
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16:
|
|
{
|
|
var arr = new short[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (short)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt16:
|
|
{
|
|
var arr = new ushort[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (ushort)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Int32:
|
|
case ModbusDataType.Bcd16:
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var arr = new int[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (int)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var arr = new uint[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (uint)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var arr = new long[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (long)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var arr = new ulong[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (ulong)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var arr = new float[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (float)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var arr = new double[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (double)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Array decode not supported for {tag.DataType} (use scalar tags or split by element)");
|
|
}
|
|
}
|
|
|
|
/// <summary>Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback.</summary>
|
|
private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;
|
|
|
|
/// <summary>
|
|
/// #148 — runtime-discovered ranges where coalesced reads have failed (typically because
|
|
/// the PLC has a write-only or protected register mid-block). Subsequent scans skip
|
|
/// coalescing across these ranges and let the per-tag fallback handle the members.
|
|
/// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API
|
|
/// (not yet shipped).
|
|
/// </summary>
|
|
/// <summary>
|
|
/// #150 — per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
|
|
/// bisection: when true and the range spans > 1 register, the next re-probe
|
|
/// tries the two halves separately to narrow the actual offending register(s).
|
|
/// Single-register prohibitions can't be split further; they stay re-probed as-is.
|
|
/// </summary>
|
|
private sealed class ProhibitionState
|
|
{
|
|
public DateTime LastProbedUtc;
|
|
public bool SplitPending;
|
|
}
|
|
|
|
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
|
|
private readonly object _autoProhibitedLock = new();
|
|
private CancellationTokenSource? _reprobeCts;
|
|
|
|
private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
foreach (var p in _autoProhibited.Keys)
|
|
{
|
|
// A candidate (start..end) range is prohibited if it overlaps any recorded
|
|
// failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about
|
|
// partial overlap — once a range fails, any superset of it is also untrusted.
|
|
if (p.Unit != unit || p.Region != region) continue;
|
|
if (Math.Max(start, p.Start) <= Math.Min(end, p.End)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
|
|
// single-register prohibitions are already minimal and skip bisection.
|
|
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = end > start,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
|
|
internal int AutoProhibitedRangeCount
|
|
{
|
|
get { lock (_autoProhibitedLock) return _autoProhibited.Count; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #151 — periodic re-probe loop, augmented in #150 with bisection-style narrowing.
|
|
/// Each tick processes every prohibition: split-pending multi-register ranges get
|
|
/// bisected (try left + right halves; replace with whichever halves still fail),
|
|
/// single-register or non-split-pending ranges get a straight re-probe. Lives for
|
|
/// the driver lifetime; cancelled by <c>ShutdownAsync</c>.
|
|
/// </summary>
|
|
private async Task ReprobeLoopAsync(CancellationToken ct)
|
|
{
|
|
var interval = _options.AutoProhibitReprobeInterval!.Value;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One re-probe pass. Public-but-internal so tests can drive it synchronously rather
|
|
/// than wait on the background timer. Iterates a snapshot of the prohibition set; for
|
|
/// each entry decides between bisection (multi-register + SplitPending) or straight
|
|
/// retry (single-register or already-narrowed).
|
|
/// </summary>
|
|
internal async Task RunReprobeOnceForTestAsync(CancellationToken ct)
|
|
{
|
|
var transport = _transport ?? throw new InvalidOperationException("Transport not connected");
|
|
|
|
((byte Unit, ModbusRegion Region, ushort Start, ushort End) Key, bool SplitPending)[] candidates;
|
|
lock (_autoProhibitedLock)
|
|
candidates = _autoProhibited
|
|
.Select(kv => (Key: kv.Key, SplitPending: kv.Value.SplitPending))
|
|
.ToArray();
|
|
|
|
foreach (var (key, splitPending) in candidates)
|
|
{
|
|
if (ct.IsCancellationRequested) return;
|
|
if (splitPending && key.End > key.Start)
|
|
await BisectAndReprobeAsync(transport, key, ct).ConfigureAwait(false);
|
|
else
|
|
await StraightReprobeAsync(transport, key, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task StraightReprobeAsync(IModbusTransport transport,
|
|
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
|
|
{
|
|
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var qty = (ushort)(key.End - key.Start + 1);
|
|
try
|
|
{
|
|
_ = await ReadRegisterBlockAsync(transport, key.Unit, fc, key.Start, qty, ct).ConfigureAwait(false);
|
|
lock (_autoProhibitedLock) _autoProhibited.Remove(key);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #150 — bisect a multi-register prohibition. Removes the parent entry and re-adds
|
|
/// whichever halves still fail. Over multiple re-probe ticks the prohibition narrows
|
|
/// log2(span) times until it pinpoints the actual protected register(s).
|
|
/// </summary>
|
|
private async Task BisectAndReprobeAsync(IModbusTransport transport,
|
|
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
|
|
{
|
|
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var mid = (ushort)((key.Start + key.End) / 2);
|
|
var leftEnd = mid;
|
|
var rightStart = (ushort)(mid + 1);
|
|
|
|
var leftFailed = await ProbeFailsAsync(transport, fc, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
|
|
var rightFailed = await ProbeFailsAsync(transport, fc, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
|
|
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
_autoProhibited.Remove(key);
|
|
if (leftFailed)
|
|
{
|
|
_autoProhibited[(key.Unit, key.Region, key.Start, leftEnd)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = leftEnd > key.Start,
|
|
};
|
|
}
|
|
if (rightFailed)
|
|
{
|
|
_autoProhibited[(key.Unit, key.Region, rightStart, key.End)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = key.End > rightStart,
|
|
};
|
|
}
|
|
// Both halves succeeded → entry is just removed. The parent prohibition is gone
|
|
// and the next normal scan can re-coalesce across the whole original range.
|
|
}
|
|
}
|
|
|
|
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, byte fc, byte unit,
|
|
ushort start, ushort end, CancellationToken ct)
|
|
{
|
|
var qty = (ushort)(end - start + 1);
|
|
try
|
|
{
|
|
_ = await ReadRegisterBlockAsync(transport, unit, fc, start, qty, ct).ConfigureAwait(false);
|
|
return false;
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch { return true; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
|
|
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
|
|
/// FC03/FC04 reads. Per-block: emit one Modbus PDU, slice the response back into per-tag
|
|
/// values, populate <paramref name="results"/> and the WriteOnChangeOnly cache. Returns
|
|
/// the set of <paramref name="fullReferences"/> indices the planner handled — the
|
|
/// caller falls back to the per-tag path for the rest (arrays, coils, prohibited, unknown).
|
|
/// </summary>
|
|
private async Task<HashSet<int>> ReadCoalescedAsync(
|
|
IModbusTransport transport,
|
|
IReadOnlyList<string> fullReferences,
|
|
DataValueSnapshot[] results,
|
|
DateTime timestamp,
|
|
CancellationToken ct)
|
|
{
|
|
// Eligible: known tag, register region (HoldingRegisters or InputRegisters), scalar
|
|
// (no array, no string), not CoalesceProhibited, not BitInRegister.
|
|
var eligible = new List<(int Index, string Ref, ModbusTagDefinition Tag)>();
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag)) continue;
|
|
if (tag.CoalesceProhibited) continue;
|
|
if (tag.ArrayCount.HasValue) continue;
|
|
if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
|
|
if (tag.DataType is ModbusDataType.String or ModbusDataType.BitInRegister) continue;
|
|
eligible.Add((i, fullReferences[i], tag));
|
|
}
|
|
if (eligible.Count == 0) return new HashSet<int>();
|
|
|
|
var handled = new HashSet<int>();
|
|
|
|
// Group by (UnitId, Region) — coalescing across slaves or regions is unsafe.
|
|
foreach (var group in eligible.GroupBy(e => (Unit: ResolveUnitId(e.Tag), e.Tag.Region)))
|
|
{
|
|
var fc = group.Key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
|
var sorted = group.OrderBy(e => e.Tag.Address).ToList();
|
|
|
|
// Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is
|
|
// the inclusive end address of the last tag's register span. A new tag joins the
|
|
// block if its start ≤ lastEnd + 1 + MaxReadGap AND the resulting span ≤ cap.
|
|
var blocks = new List<(ushort Start, ushort End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
|
|
foreach (var (idx, _, tag) in sorted)
|
|
{
|
|
var tagStart = tag.Address;
|
|
var tagEnd = (ushort)(tag.Address + RegisterCount(tag) - 1);
|
|
if (blocks.Count > 0)
|
|
{
|
|
var last = blocks[^1];
|
|
var gap = tagStart - last.End - 1;
|
|
var newEnd = Math.Max(tagEnd, last.End);
|
|
var newSpan = newEnd - last.Start + 1;
|
|
// #148 — skip merges that would re-attempt a known-bad range. The
|
|
// per-tag fallback will read each member individually instead.
|
|
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
|
|
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
|
|
{
|
|
last.Members.Add((idx, tag));
|
|
blocks[^1] = (last.Start, (ushort)newEnd, last.Members);
|
|
continue;
|
|
}
|
|
}
|
|
blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
|
|
}
|
|
|
|
// Issue one PDU per block. On block-level failure mark every member Bad — caller's
|
|
// per-tag fallback won't re-try since handled-set already includes them; auto-split-
|
|
// on-failure is a follow-up.
|
|
foreach (var block in blocks)
|
|
{
|
|
if (block.Members.Count == 1)
|
|
{
|
|
// Lone tag — let the per-tag path handle it for symmetry with WriteOnChange
|
|
// cache invalidation. Costs nothing because one tag = one PDU either way.
|
|
continue;
|
|
}
|
|
|
|
var qty = (ushort)(block.End - block.Start + 1);
|
|
try
|
|
{
|
|
var data = await ReadRegisterBlockAsync(transport, group.Key.Unit, fc, block.Start, qty, ct).ConfigureAwait(false);
|
|
foreach (var (idx, tag) in block.Members)
|
|
{
|
|
var sliceOffsetBytes = (tag.Address - block.Start) * 2;
|
|
var sliceLenBytes = RegisterCount(tag) * 2;
|
|
var value = DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag);
|
|
results[idx] = new DataValueSnapshot(value, 0u, timestamp, timestamp);
|
|
handled.Add(idx);
|
|
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
|
|
}
|
|
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
|
|
}
|
|
catch (ModbusException mex)
|
|
{
|
|
// #148 — record the failed range so the planner stops re-coalescing across
|
|
// it on subsequent scans. Per-tag fallback reads each member individually
|
|
// next time, so healthy tags around the protected hole keep working without
|
|
// operator intervention.
|
|
RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
|
|
|
|
var status = MapModbusExceptionToStatus(mex.ExceptionCode);
|
|
foreach (var (idx, _) in block.Members)
|
|
{
|
|
// Don't mark members handled — leave them for the per-tag fallback in
|
|
// the same scan so single-register reads can succeed for any non-
|
|
// protected member. (Pre-#148 behaviour was to mark all Bad and skip.)
|
|
// Members that ARE the protected register will fail again at single-tag
|
|
// granularity and surface the per-tag exception code naturally.
|
|
}
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Communication failures (timeout, socket drop) aren't a structural reason
|
|
// to prohibit the range — the same coalesced read might succeed once the
|
|
// transport recovers. Mark members Bad for this scan but don't auto-prohibit
|
|
// and don't deflect to per-tag fallback (which would just hit the same dead
|
|
// socket).
|
|
foreach (var (idx, _) in block.Members)
|
|
{
|
|
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
|
|
handled.Add(idx);
|
|
}
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
|
|
{
|
|
if (!_options.WriteOnChangeOnly) return;
|
|
lock (_lastWrittenLock)
|
|
{
|
|
if (_lastWrittenByRef.TryGetValue(fullRef, out var prev) && !Equals(prev, value))
|
|
_lastWrittenByRef.Remove(fullRef);
|
|
}
|
|
}
|
|
|
|
|
|
private async Task<byte[]> ReadRegisterBlockAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
|
{
|
|
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
|
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
|
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
// resp = [fc][byte-count][data...]
|
|
var data = new byte[resp[1]];
|
|
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
|
return data;
|
|
}
|
|
|
|
private async Task<byte[]> ReadBitBlockAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort qty, CancellationToken ct)
|
|
{
|
|
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
|
(byte)(qty >> 8), (byte)(qty & 0xFF) };
|
|
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
var bitmap = new byte[resp[1]];
|
|
Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]);
|
|
return bitmap;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into
|
|
/// one logical bitmap byte array sized for the full <paramref name="totalBits"/>; the
|
|
/// downstream <see cref="DecodeBitArray"/> walks bits LSB-first the same way it would
|
|
/// for a single-chunk response.
|
|
/// </summary>
|
|
private async Task<byte[]> ReadBitBlockChunkedAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, int totalBits, ushort cap, CancellationToken ct)
|
|
{
|
|
var assembled = new byte[(totalBits + 7) / 8];
|
|
var done = 0;
|
|
while (done < totalBits)
|
|
{
|
|
var chunk = (ushort)Math.Min(cap, totalBits - done);
|
|
var chunkBitmap = await ReadBitBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
|
// Re-pack per-chunk LSB-first bits into the assembled bitmap at the right offset.
|
|
for (var i = 0; i < chunk; i++)
|
|
{
|
|
if (((chunkBitmap[i / 8] >> (i % 8)) & 0x01) == 0) continue;
|
|
var dest = done + i;
|
|
assembled[dest / 8] |= (byte)(1 << (dest % 8));
|
|
}
|
|
done += chunk;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
|
{
|
|
var assembled = new byte[totalRegs * 2];
|
|
ushort done = 0;
|
|
while (done < totalRegs)
|
|
{
|
|
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
|
var chunkBytes = await ReadRegisterBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
|
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
|
done += chunk;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
var transport = RequireTransport();
|
|
var results = new WriteResult[writes.Count];
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
var w = writes[i];
|
|
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
|
{
|
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
|
continue;
|
|
}
|
|
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
|
{
|
|
results[i] = new WriteResult(StatusBadNotWritable);
|
|
continue;
|
|
}
|
|
// #141 WriteOnChangeOnly suppression: skip the wire round-trip when the same value
|
|
// was already successfully written and no read since has invalidated the cache.
|
|
if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
|
|
{
|
|
results[i] = new WriteResult(0u);
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
|
results[i] = new WriteResult(0u);
|
|
if (_options.WriteOnChangeOnly)
|
|
lock (_lastWrittenLock) _lastWrittenByRef[w.FullReference] = w.Value;
|
|
}
|
|
catch (ModbusException mex)
|
|
{
|
|
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
results[i] = new WriteResult(StatusBadInternalError);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private bool IsRedundantWrite(string tagRef, object? value)
|
|
{
|
|
lock (_lastWrittenLock)
|
|
{
|
|
if (!_lastWrittenByRef.TryGetValue(tagRef, out var prev)) return false;
|
|
// Object.Equals handles boxed-numeric equality (5 == 5 even if one was short and
|
|
// one int through boxing). For arrays we deliberately don't suppress — equality
|
|
// semantics on arrays are reference-only so the cache miss is the safer answer.
|
|
if (prev is null || value is null) return Equals(prev, value);
|
|
if (prev is Array || value is Array) return false;
|
|
return prev.Equals(value);
|
|
}
|
|
}
|
|
|
|
// BitInRegister writes need a read-modify-write against the full holding register. A
|
|
// per-register lock keeps concurrent bit-write callers from stomping on each other —
|
|
// Write bit 0 and Write bit 5 targeting the same register can arrive on separate
|
|
// subscriber threads, and without serialising the RMW the second-to-commit value wins
|
|
// + the first bit update is lost.
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
|
|
|
|
private SemaphoreSlim GetRmwLock(ushort address) =>
|
|
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
|
|
|
|
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
|
{
|
|
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
|
|
// write sequence doesn't hit EncodeRegister's defensive throw.
|
|
if (tag.DataType == ModbusDataType.BitInRegister &&
|
|
tag.Region is ModbusRegion.HoldingRegisters)
|
|
{
|
|
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
switch (tag.Region)
|
|
{
|
|
case ModbusRegion.Coils:
|
|
{
|
|
if (!tag.ArrayCount.HasValue && !_options.UseFC15ForSingleCoilWrites)
|
|
{
|
|
var on = Convert.ToBoolean(value);
|
|
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
// FC15 path: either an explicit array, or UseFC15ForSingleCoilWrites=true forced
|
|
// it for a scalar (synthesise a 1-element bool[] from the scalar value).
|
|
var arrayLen = tag.ArrayCount ?? 1;
|
|
if (!tag.ArrayCount.HasValue)
|
|
value = new[] { Convert.ToBoolean(value) };
|
|
// FC15 — Write Multiple Coils. Pack the bool[] into LSB-first bitmap.
|
|
var values = ToBoolArray(value, arrayLen, tag.Name);
|
|
var byteCount = (values.Length + 7) / 8;
|
|
var bitmap = new byte[byteCount];
|
|
for (var i = 0; i < values.Length; i++)
|
|
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
|
|
var qty = (ushort)values.Length;
|
|
var pdu15 = new byte[6 + 1 + byteCount];
|
|
pdu15[0] = 0x0F;
|
|
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
|
|
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
|
|
pdu15[5] = (byte)byteCount;
|
|
Buffer.BlockCopy(bitmap, 0, pdu15, 6, byteCount);
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu15, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
case ModbusRegion.HoldingRegisters:
|
|
{
|
|
var bytes = tag.ArrayCount.HasValue
|
|
? EncodeRegisterArray(value, tag)
|
|
: EncodeRegister(value, tag);
|
|
|
|
if (bytes.Length == 2 && !tag.ArrayCount.HasValue && !_options.UseFC16ForSingleRegisterWrites)
|
|
{
|
|
// FC06 fast-path for single-register scalar writes only. Arrays always use
|
|
// FC16 even when the array is one element wide, because the encoder shape
|
|
// may need it. UseFC16ForSingleRegisterWrites=true forces FC16 even here for
|
|
// PLCs that only accept the multi-write codes.
|
|
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
bytes[0], bytes[1] };
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
// FC 16 (Write Multiple Registers) for 32-bit / 64-bit / array / string types.
|
|
var qty = (ushort)(bytes.Length / 2);
|
|
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
|
if (qty > writeCap)
|
|
throw new InvalidOperationException(
|
|
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
|
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
|
|
var pdu = new byte[6 + 1 + bytes.Length];
|
|
pdu[0] = 0x10;
|
|
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
|
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
|
pdu[5] = (byte)bytes.Length;
|
|
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encode an array-typed write value into a contiguous byte block by encoding each
|
|
/// element with the scalar codec. Caller submits IList / Array of the element CLR type.
|
|
/// </summary>
|
|
private static byte[] EncodeRegisterArray(object? value, ModbusTagDefinition tag)
|
|
{
|
|
var count = tag.ArrayCount!.Value;
|
|
if (value is not System.Collections.IList list || list.Count != count)
|
|
throw new InvalidOperationException(
|
|
$"Array write to {tag.Name} expects an IList of length {count}; got {value?.GetType().Name ?? "null"}");
|
|
|
|
var elementBytes = ElementByteCount(tag);
|
|
var result = new byte[count * elementBytes];
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var element = list[i];
|
|
var encoded = EncodeRegister(element, tag);
|
|
if (encoded.Length != elementBytes)
|
|
throw new InvalidOperationException(
|
|
$"Encoder returned {encoded.Length} bytes for element {i} of {tag.Name}, expected {elementBytes}");
|
|
Buffer.BlockCopy(encoded, 0, result, i * elementBytes, elementBytes);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static bool[] ToBoolArray(object? value, int expectedCount, string tagName)
|
|
{
|
|
if (value is bool[] direct && direct.Length == expectedCount) return direct;
|
|
if (value is System.Collections.IList list && list.Count == expectedCount)
|
|
{
|
|
var arr = new bool[expectedCount];
|
|
for (var i = 0; i < expectedCount; i++) arr[i] = Convert.ToBoolean(list[i]);
|
|
return arr;
|
|
}
|
|
throw new InvalidOperationException(
|
|
$"Coil-array write to {tagName} expects a bool[] (or convertible IList) of length {expectedCount}; got {value?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
private static int ElementByteCount(ModbusTagDefinition tag) => tag.DataType switch
|
|
{
|
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.Bcd16 => 2,
|
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 4,
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 8,
|
|
_ => throw new InvalidOperationException($"Element byte count not defined for {tag.DataType} in array context"),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
|
|
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
|
|
/// </summary>
|
|
private async Task WriteBitInRegisterAsync(
|
|
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
|
{
|
|
var bit = tag.BitIndex;
|
|
if (bit > 15)
|
|
throw new InvalidOperationException(
|
|
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
|
|
var on = Convert.ToBoolean(value);
|
|
|
|
var rmwLock = GetRmwLock(tag.Address);
|
|
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// FC03 read 1 holding register at tag.Address.
|
|
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
|
var readResp = await transport.SendAsync(ResolveUnitId(tag), readPdu, ct).ConfigureAwait(false);
|
|
// resp = [fc][byte-count=2][hi][lo]
|
|
var current = (ushort)((readResp[2] << 8) | readResp[3]);
|
|
|
|
var updated = on
|
|
? (ushort)(current | (1 << bit))
|
|
: (ushort)(current & ~(1 << bit));
|
|
|
|
// FC06 write single holding register.
|
|
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
(byte)(updated >> 8), (byte)(updated & 0xFF) };
|
|
await transport.SendAsync(ResolveUnitId(tag), writePdu, ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
rmwLock.Release();
|
|
}
|
|
}
|
|
|
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
|
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
|
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
|
|
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
_poll.Unsubscribe(handle);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
|
{
|
|
lock (_probeLock)
|
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
|
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
|
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
|
/// </summary>
|
|
public string HostName => $"{_options.Host}:{_options.Port}";
|
|
|
|
private async Task ProbeLoopAsync(CancellationToken ct)
|
|
{
|
|
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var success = false;
|
|
try
|
|
{
|
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
probeCts.CancelAfter(_options.Probe.Timeout);
|
|
var pdu = new byte[] { 0x03,
|
|
(byte)(_options.Probe.ProbeAddress >> 8),
|
|
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
|
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
|
success = true;
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
// transport / timeout / exception PDU — treated as Stopped below
|
|
}
|
|
|
|
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
|
|
|
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
}
|
|
}
|
|
|
|
private void TransitionTo(HostState newState)
|
|
{
|
|
HostState old;
|
|
lock (_probeLock)
|
|
{
|
|
old = _hostState;
|
|
if (old == newState) return;
|
|
_hostState = newState;
|
|
_hostStateChangedUtc = DateTime.UtcNow;
|
|
}
|
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
|
}
|
|
|
|
// ---- codec ----
|
|
|
|
/// <summary>
|
|
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
|
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
|
/// from 2 chars per register).
|
|
/// </summary>
|
|
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
|
{
|
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
|
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
|
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
|
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Re-order the input bytes into the big-endian (ABCD) layout the decoders expect.
|
|
/// The four orders refer to how bytes A, B, C, D appear on the wire when reading a
|
|
/// 32-bit value from two consecutive registers (extends pairwise for 64-bit / 4 regs):
|
|
/// <list type="bullet">
|
|
/// <item><b>BigEndian (ABCD)</b>: bytes as-is — Modbus spec default.</item>
|
|
/// <item><b>WordSwap (CDAB)</b>: swap word pairs (full register reversal across the value).</item>
|
|
/// <item><b>ByteSwap (BADC)</b>: swap bytes within each register.</item>
|
|
/// <item><b>FullReverse (DCBA)</b>: full byte reversal — equivalent to little-endian.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
|
{
|
|
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
|
|
|
var result = new byte[data.Length];
|
|
var registers = data.Length / 2;
|
|
|
|
switch (order)
|
|
{
|
|
case ModbusByteOrder.WordSwap:
|
|
// Reverse register order; bytes within each register stay big-endian.
|
|
for (var word = 0; word < registers; word++)
|
|
{
|
|
var srcWord = registers - 1 - word;
|
|
result[word * 2] = data[srcWord * 2];
|
|
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
|
}
|
|
break;
|
|
case ModbusByteOrder.ByteSwap:
|
|
// Keep register order, swap two bytes within each register.
|
|
for (var word = 0; word < registers; word++)
|
|
{
|
|
result[word * 2] = data[word * 2 + 1];
|
|
result[word * 2 + 1] = data[word * 2];
|
|
}
|
|
break;
|
|
case ModbusByteOrder.FullReverse:
|
|
// Full byte-by-byte reversal — equivalent to interpreting the value little-endian.
|
|
for (var i = 0; i < data.Length; i++)
|
|
result[i] = data[data.Length - 1 - i];
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unhandled byte order {order}");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
|
{
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
|
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
case ModbusDataType.Bcd16:
|
|
{
|
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
return (int)DecodeBcd(raw, nibbles: 4);
|
|
}
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
|
|
return (int)DecodeBcd(raw, nibbles: 8);
|
|
}
|
|
case ModbusDataType.BitInRegister:
|
|
{
|
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
return (raw & (1 << tag.BitIndex)) != 0;
|
|
}
|
|
case ModbusDataType.Int32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadInt32BigEndian(b);
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadSingleBigEndian(b);
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadInt64BigEndian(b);
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
|
}
|
|
case ModbusDataType.String:
|
|
{
|
|
// ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
|
|
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
|
|
// in the low byte. Respect StringLength (truncate nul-padded regions).
|
|
var chars = new char[tag.StringLength];
|
|
for (var i = 0; i < tag.StringLength; i++)
|
|
{
|
|
var regIdx = i / 2;
|
|
var highByte = data[regIdx * 2];
|
|
var lowByte = data[regIdx * 2 + 1];
|
|
byte b;
|
|
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
|
|
b = (i % 2 == 0) ? highByte : lowByte;
|
|
else
|
|
b = (i % 2 == 0) ? lowByte : highByte;
|
|
if (b == 0) return new string(chars, 0, i);
|
|
chars[i] = (char)b;
|
|
}
|
|
return new string(chars);
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
|
}
|
|
}
|
|
|
|
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
|
{
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16:
|
|
{
|
|
var v = Convert.ToInt16(value);
|
|
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
|
}
|
|
case ModbusDataType.UInt16:
|
|
{
|
|
var v = Convert.ToUInt16(value);
|
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
|
}
|
|
case ModbusDataType.Bcd16:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
|
|
var raw = (ushort)EncodeBcd(v, nibbles: 4);
|
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
|
|
}
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
|
|
var raw = EncodeBcd(v, nibbles: 8);
|
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Int32:
|
|
{
|
|
var v = Convert.ToInt32(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var v = Convert.ToSingle(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var v = Convert.ToInt64(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var v = Convert.ToUInt64(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var v = Convert.ToDouble(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.String:
|
|
{
|
|
var s = Convert.ToString(value) ?? string.Empty;
|
|
var regs = (tag.StringLength + 1) / 2;
|
|
var b = new byte[regs * 2];
|
|
for (var i = 0; i < tag.StringLength && i < s.Length; i++)
|
|
{
|
|
var regIdx = i / 2;
|
|
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
|
|
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
|
|
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
|
|
b[destIdx] = (byte)s[i];
|
|
}
|
|
// remaining bytes stay 0 — nul-padded per PLC convention
|
|
return b;
|
|
}
|
|
case ModbusDataType.BitInRegister:
|
|
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
|
|
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
|
|
// the RMW shortcut in WriteOneAsync.
|
|
throw new InvalidOperationException(
|
|
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
|
|
default:
|
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
|
}
|
|
}
|
|
|
|
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
|
{
|
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
|
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
|
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
|
|
ModbusDataType.Float32 => DriverDataType.Float32,
|
|
ModbusDataType.Float64 => DriverDataType.Float64,
|
|
ModbusDataType.String => DriverDataType.String,
|
|
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
|
|
_ => DriverDataType.Int32,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
|
|
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles > 9 —
|
|
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
|
|
/// would quietly corrupt the caller's data.
|
|
/// </summary>
|
|
internal static uint DecodeBcd(uint raw, int nibbles)
|
|
{
|
|
uint result = 0;
|
|
for (var i = nibbles - 1; i >= 0; i--)
|
|
{
|
|
var digit = (raw >> (i * 4)) & 0xF;
|
|
if (digit > 9)
|
|
throw new InvalidDataException(
|
|
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
|
|
result = result * 10 + digit;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
|
|
/// against the nibble capacity (10^nibbles - 1).
|
|
/// </summary>
|
|
internal static uint EncodeBcd(uint value, int nibbles)
|
|
{
|
|
uint result = 0;
|
|
for (var i = 0; i < nibbles; i++)
|
|
{
|
|
var digit = value % 10;
|
|
result |= digit << (i * 4);
|
|
value /= 10;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private IModbusTransport RequireTransport() =>
|
|
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
|
|
|
private const uint StatusBadInternalError = 0x80020000u;
|
|
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
|
private const uint StatusBadNotWritable = 0x803B0000u;
|
|
private const uint StatusBadOutOfRange = 0x803C0000u;
|
|
private const uint StatusBadNotSupported = 0x803D0000u;
|
|
private const uint StatusBadDeviceFailure = 0x80550000u;
|
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
|
|
|
/// <summary>
|
|
/// Map a server-returned Modbus exception code to the most informative OPC UA
|
|
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
|
|
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
|
|
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
|
|
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
|
|
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
|
|
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
|
|
/// extensions.
|
|
/// </summary>
|
|
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
|
|
{
|
|
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
|
|
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
|
|
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
|
|
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
|
|
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
|
|
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
|
|
_ => StatusBadInternalError,
|
|
};
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
|
_transport = null;
|
|
}
|
|
}
|