fix(driver-modbus): resolve Low code-review findings (Driver.Modbus-003,007,008,009,010,011,012)
- Driver.Modbus-003: route every _health access through ReadHealth / WriteHealth helpers backed by Volatile.Read / Volatile.Write so a burst of concurrent ReadAsync callers always sees a complete snapshot. - Driver.Modbus-007: promoted the Int64 / UInt64 → Int32 surfacing caveat to a full <remarks> block; rewrote DisableFC23's doc to flag it as reserved / no-op. - Driver.Modbus-008: deleted stale duplicate doc, rewrote the prohibition-block summaries to credit the shipped re-probe loop, and removed the unused 'status' local in the ModbusException catch arm. - Driver.Modbus-009: bind-time validation rejects StringLength < 1 for String tags; ModbusTcpTransport clamps keep-alive intervals to whole seconds (>=1). - Driver.Modbus-010: documented WriteOnChangeOnly's cache-invalidation policy (reads-only) and the write-only-tag caveat. - Driver.Modbus-011: collected the scattered instance fields into a single contiguous block at the top of ModbusDriver. - Driver.Modbus-012: covered the previously-uncovered Reinitialize state-hygiene, malformed/truncated/empty-bitmap response, and DisposeAsync teardown paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,30 +21,40 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
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;
|
||||
}
|
||||
// ---- instance fields (Driver.Modbus-011: grouped at top for auditability) ----
|
||||
|
||||
/// <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}";
|
||||
private readonly ModbusDriverOptions _options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<ModbusDriver> _logger;
|
||||
|
||||
// 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;
|
||||
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 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.
|
||||
// Driver.Modbus-001: ShouldPublish runs on the PollGroupEngine onChange callback, which
|
||||
// executes on one background Task per subscription — so a multi-subscription driver mutates
|
||||
// this map concurrently from several threads. A plain Dictionary corrupts under concurrent
|
||||
// writes; ConcurrentDictionary makes every TryGetValue / indexer write thread-safe.
|
||||
private readonly ConcurrentDictionary<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();
|
||||
|
||||
// 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.
|
||||
private readonly ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
// #148 auto-prohibited coalesce ranges + #150 bisection state (see ProhibitionState below).
|
||||
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
|
||||
private readonly object _autoProhibitedLock = new();
|
||||
|
||||
// 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
|
||||
@@ -52,15 +62,39 @@ public sealed class ModbusDriver
|
||||
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);
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private CancellationTokenSource? _reprobeCts;
|
||||
|
||||
private readonly ILogger<ModbusDriver> _logger;
|
||||
// Driver.Modbus-003: every read / write / probe path writes to _health from a different
|
||||
// thread, and GetHealth() reads it without coordination. Reference-assignment on .NET is
|
||||
// atomic for sealed-record refs (so no tearing), but without a happens-before barrier a
|
||||
// stale snapshot can persist on another core indefinitely. Volatile.Write / Volatile.Read
|
||||
// give GetHealth() a defined ordering guarantee: any subsequent read sees at least the
|
||||
// most recent write any thread has published. The field stays a plain reference (you can't
|
||||
// mark a record-typed field 'volatile' through the C# keyword on every framework version,
|
||||
// and the Volatile API is the documented portable form).
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// ---- nested types ----
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// ---- ctor + identity ----
|
||||
|
||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||
@@ -87,19 +121,22 @@ public sealed class ModbusDriver
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Driver.Modbus-001: ShouldPublish runs on the PollGroupEngine onChange callback, which
|
||||
// executes on one background Task per subscription — so a multi-subscription driver mutates
|
||||
// this map concurrently from several threads. A plain Dictionary corrupts under concurrent
|
||||
// writes; ConcurrentDictionary makes every TryGetValue / indexer write thread-safe.
|
||||
private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
/// <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}";
|
||||
|
||||
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
@@ -131,13 +168,13 @@ public sealed class ModbusDriver
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
WriteHealth(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);
|
||||
WriteHealth(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
|
||||
@@ -157,7 +194,7 @@ public sealed class ModbusDriver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -170,12 +207,25 @@ public sealed class ModbusDriver
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastRead = _health.LastSuccessfulRead;
|
||||
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Unknown, lastRead, null);
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public DriverHealth GetHealth() => ReadHealth();
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Modbus-003: barrier-protected read of the multi-thread <c>_health</c> field.
|
||||
/// <c>Volatile.Read</c> guarantees <c>GetHealth()</c> and the in-driver self-reads (the
|
||||
/// Degraded paths that retain <c>LastSuccessfulRead</c>) observe the most recently
|
||||
/// published snapshot rather than a per-core cached stale copy.
|
||||
/// </summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Modbus-003: barrier-protected publish of a new <c>_health</c> snapshot.
|
||||
/// </summary>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -228,7 +278,7 @@ public sealed class ModbusDriver
|
||||
{
|
||||
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
WriteHealth(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
|
||||
@@ -246,14 +296,14 @@ public sealed class ModbusDriver
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
||||
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().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);
|
||||
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
@@ -402,29 +452,6 @@ public sealed class ModbusDriver
|
||||
/// <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)
|
||||
@@ -700,9 +727,13 @@ public sealed class ModbusDriver
|
||||
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.
|
||||
// Issue one PDU per block. On a Modbus-level exception (illegal data address /
|
||||
// protected register), record the range as auto-prohibited (#148), leave the
|
||||
// member indices UNhandled, and let the per-tag fallback in ReadAsync read each
|
||||
// surviving address individually. On transport-level failure (timeout / socket
|
||||
// drop) mark members Bad and short-circuit the per-tag fallback (hitting the
|
||||
// dead socket again won't help). #150 bisection narrows the prohibition over
|
||||
// subsequent re-probe ticks.
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
if (block.Members.Count == 1)
|
||||
@@ -725,26 +756,19 @@ public sealed class ModbusDriver
|
||||
handled.Add(idx);
|
||||
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
|
||||
WriteHealth(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.
|
||||
// it on subsequent scans. The members are intentionally NOT added to the
|
||||
// handled-set: ReadAsync's per-tag fallback runs them individually in the
|
||||
// same scan, so healthy tags around the protected hole keep working without
|
||||
// operator intervention. Members that ARE the protected register will fail
|
||||
// again at single-tag granularity and surface the per-tag exception code
|
||||
// naturally — the block-level mex isn't propagated.
|
||||
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);
|
||||
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -758,7 +782,7 @@ public sealed class ModbusDriver
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
|
||||
handled.Add(idx);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -926,13 +950,11 @@ public sealed class ModbusDriver
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// BitInRegister writes need a read-modify-write against the full holding register. The
|
||||
// per-register lock (declared at the top of the class) 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 SemaphoreSlim GetRmwLock(ushort address) =>
|
||||
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
@@ -1410,12 +1432,34 @@ public sealed class ModbusDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a Modbus logical type to the driver-agnostic <see cref="DriverDataType"/> used
|
||||
/// by the address-space builder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Driver.Modbus-007 — Int64 / UInt64 surfacing limitation:</b>
|
||||
/// <see cref="DriverDataType"/> does not yet include an Int64 enum member, so 64-bit
|
||||
/// Modbus tags currently surface as <see cref="DriverDataType.Int32"/> on the OPC UA
|
||||
/// address space. The wire codec (<c>DecodeRegister</c> / <c>EncodeRegister</c>) is
|
||||
/// correct — values round-trip as 64-bit <c>long</c> / <c>ulong</c> through
|
||||
/// <c>ReadAsync</c> / <c>WriteAsync</c>. Only the variable node's <c>DataType</c>
|
||||
/// attribute is misreported. Clients that consume the type advertisement will see a
|
||||
/// type/value mismatch for values outside the 32-bit signed range. Operators
|
||||
/// configuring <c>I_64</c> / <c>UI_64</c> tags should be aware of this until the
|
||||
/// tracked <c>DriverDataType.Int64</c> follow-up ships.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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
|
||||
// Driver.Modbus-007: Int64 / UInt64 currently surface as Int32 because DriverDataType
|
||||
// has no Int64 member yet. The wire codec preserves the 64-bit value; only the OPC UA
|
||||
// node's declared DataType is widened. Tracked for a follow-up that adds the enum
|
||||
// member + node-type advertisement.
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32,
|
||||
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||
ModbusDataType.String => DriverDataType.String,
|
||||
|
||||
Reference in New Issue
Block a user